落尘的博客

真正的英雄主义,是在明白生活的本质之后,依旧能够热爱生活。

0%

1
2
3
4
5
title: SSM
date: 2022-01-01 00:00:00
tags: SSM
categories: SSM
comment

MyBatis

基本介绍

ORM(Object Relational Mapping): 对象关系映射,指的是持久化数据和实体对象的映射模式,解决面向对象与关系型数据库存在的互不匹配的现象

MyBatis

  • MyBatis 是一个优秀的基于 Java 的持久层框架,它内部封装了 JDBC,使开发者只需关注 SQL 语句本身,而不需要花费精力去处理加载驱动、创建连接、创建 Statement 等过程。

  • MyBatis 通过 XML 或注解的方式将要执行的各种 Statement 配置起来,并通过 Java 对象和 Statement 中 SQL 的动态参数进行映射生成最终执行的 SQL 语句。

  • MyBatis 框架执行 SQL 并将结果映射为 Java 对象并返回。采用 ORM 思想解决了实体和数据库映射的问题,对 JDBC 进行了封装,屏蔽了 JDBC 底层 API 的调用细节,使我们不用操作 JDBC API,就可以完成对数据库的持久化操作。

MyBatis 官网地址:http://www.mybatis.org/mybatis-3/

参考视频:https://space.bilibili.com/37974444/


基本操作

相关API

Resources:加载资源的工具类

  • InputStream getResourceAsStream(String fileName):通过类加载器返回指定资源的字节流
    • 参数 fileName 是放在 src 的核心配置文件名:MyBatisConfig.xml

SqlSessionFactoryBuilder:构建器,用来获取 SqlSessionFactory 工厂对象

  • SqlSessionFactory build(InputStream is):通过指定资源的字节输入流获取 SqlSession 工厂对象

SqlSessionFactory:获取 SqlSession 构建者对象的工厂接口

  • SqlSession openSession():获取 SqlSession 构建者对象,并开启手动提交事务
  • SqlSession openSession(boolean):获取 SqlSession 构建者对象,参数为 true 开启自动提交事务

SqlSession:构建者对象接口,用于执行 SQL、管理事务、接口代理

  • SqlSession 代表和数据库的一次会话,用完必须关闭
  • SqlSession 和 Connection 一样都是非线程安全,每次使用都应该去获取新的对象

注:update 数据需要提交事务,或开启默认提交

SqlSession 常用 API:

方法 说明
List selectList(String statement,Object parameter) 执行查询语句,返回List集合
T selectOne(String statement,Object parameter) 执行查询语句,返回一个结果对象
int insert(String statement,Object parameter) 执行新增语句,返回影响行数
int update(String statement,Object parameter) 执行删除语句,返回影响行数
int delete(String statement,Object parameter) 执行修改语句,返回影响行数
void commit() 提交事务
void rollback() 回滚事务
T getMapper(Class cls) 获取指定接口的代理实现类对象
void close() 释放资源

映射配置

映射配置文件包含了数据和对象之间的映射关系以及要执行的 SQL 语句,放在 src 目录下

命名:StudentMapper.xml

  • 映射配置文件的文件头:

    1
    2
    3
    4
    <?xml version="1.0" encoding="UTF-8" ?>
    <!DOCTYPE mapper
    PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
  • 根标签:

    • :核心根标签
    • namespace:属性,名称空间
  • 功能标签:

    • < select >:查询功能标签
    • :新增功能标签
    • :修改功能标签
    • :删除功能标签
      • id:属性,唯一标识,配合名称空间使用
      • resultType:指定结果映射对象类型,和对应的方法的返回值类型(全限定名)保持一致,但是如果返回值是 List 则和其泛型保持一致
      • parameterType:指定参数映射对象类型,必须和对应的方法的参数类型(全限定名)保持一致
      • statementType:可选 STATEMENT,PREPARED 或 CALLABLE,默认值:PREPARED
        • STATEMENT:直接操作 SQL,使用 Statement 不进行预编译,获取数据:$
        • PREPARED:预处理参数,使用 PreparedStatement 进行预编译,获取数据:#
        • CALLABLE:执行存储过程,CallableStatement
  • 参数获取方式:

    • SQL 获取参数:#{属性名}

      1
      2
      3
      4
      5
      <mapper namespace="StudentMapper">
      <select id="selectById" resultType="student" parameterType="int">
      SELECT * FROM student WHERE id = #{id}
      </select>
      <mapper/>

强烈推荐官方文档:https://mybatis.org/mybatis-3/zh/sqlmap-xml.html


核心配置

核心配置文件包含了 MyBatis 最核心的设置和属性信息,如数据库的连接、事务、连接池信息等

命名:MyBatisConfig.xml

  • 核心配置文件的文件头:

    1
    2
    <?xml version="1.0" encoding="UTF-8" ?>
    <!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd">
  • 根标签:

    • :核心根标签
  • 引入连接配置文件:

    • : 引入数据库连接配置文件标签

      • resource:属性,指定配置文件名
      1
      <properties resource="jdbc.properties"/>
  • 调整设置

    • :可以改变 Mybatis 运行时行为
  • 起别名:

    • :为全类名起别名的父标签

      • :为全类名起别名的子标签

        • type:指定全类名
        • alias:指定别名
      • :为指定包下所有类起别名的子标签,别名就是类名,首字母小写

      1
      2
      3
      4
      5
      6
      <!--起别名-->
      <typeAliases>
      <typeAlias type="bean.Student" alias="student"/>
      <package name="com.seazean.bean"/>
      <!--二选一-->
      </typeAliase>
    • 自带别名:

      别名 数据类型
      string java.lang.String
      long java.lang.Lang
      int java.lang.Integer
      double java.lang.Double
      boolean java.lang.Boolean
      …. ……
  • 配置环境,可以配置多个标签

    • :配置数据库环境标签,default 属性指定哪个 environment
    • :配置数据库环境子标签,id 属性是唯一标识,与 default 对应
    • :事务管理标签,type 属性默认 JDBC 事务
    • :数据源标签
      • type 属性:POOLED 使用连接池(MyBatis 内置),UNPOOLED 不使用连接池
    • :数据库连接信息标签。
      • name 属性取值:driver,url,username,password
      • value 属性取值:与 name 对应
  • 引入映射配置文件

    • :引入映射配置文件标签
    • :引入映射配置文件子标签
      • resource:属性指定映射配置文件的名称
      • url:引用网路路径或者磁盘路径下的 sql 映射文件
      • class:指定映射配置类
    • :批量注册

参考官方文档:https://mybatis.org/mybatis-3/zh/configuration.html


#{}和${}

#{}:占位符,传入的内容会作为字符串加上引号,以预编译的方式传入,将 sql 中的 #{} 替换为 ? 号,调用 PreparedStatement 的 set 方法来赋值,有效的防止 SQL 注入,提高系统安全性

${}:拼接符,传入的内容会直接替换拼接,不会加上引号,可能存在 sql 注入的安全隐患

  • 能用 #{} 的地方就用 #{},不用或少用 ${}

  • 必须使用 ${} 的情况:

    • 表名作参数时,如:SELECT * FROM ${tableName}
    • order by 时,如:SELECT * FROM t_user ORDER BY ${columnName}
  • sql 语句使用 #{},properties 文件内容获取使用 ${}


日志文件

在日常开发过程中,排查问题时需要输出 MyBatis 真正执行的 SQL 语句、参数、结果等信息,就可以借助 log4j 的功能来实现执行信息的输出。

  • 在核心配置文件根标签内配置 log4j

    1
    2
    3
    4
    <!--配置LOG4J-->
    <settings>
    <setting name="logImpl" value="log4j"/>
    </settings>
  • 在 src 目录下创建 log4j.properties

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    # Global logging configuration
    log4j.rootLogger=DEBUG, stdout
    # Console output...
    log4j.appender.stdout=org.apache.log4j.ConsoleAppender
    log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
    log4j.appender.stdout.layout.ConversionPattern=%5p [%t] - %m%n

    #输出到日志文件
    #log4j.appender.file=org.apache.log4j.FileAppender
    #log4j.appender.file.File=../logs/iask.log
    #log4j.appender.file.layout=org.apache.log4j.PatternLayout
    #log4j.appender.file.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss} %l %m%n
  • pom.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>1.7.21</version>
    </dependency>
    <dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-log4j12</artifactId>
    <version>1.7.21</version>
    </dependency>

代码实现

  • 实体类

    1
    2
    3
    4
    5
    6
    public class Student {
    private Integer id;
    private String name;
    private Integer age;
    .....
    }
  • StudentMapper

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public interface StudentMapper {
    //查询全部
    public abstract List<Student> selectAll();

    //根据id查询
    public abstract Student selectById(Integer id);

    //新增数据
    public abstract Integer insert(Student stu);

    //修改数据
    public abstract Integer update(Student stu);

    //删除数据
    public abstract Integer delete(Integer id);
    }
  • config.properties

    1
    2
    3
    4
    driver=com.mysql.jdbc.Driver
    url=jdbc:mysql://192.168.2.184:3306/db1
    username=root
    password=123456
  • MyBatisConfig.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    <?xml version="1.0" encoding="UTF-8" ?>
    <!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd">

    <!--核心根标签-->
    <configuration>
    <!--引入数据库连接的配置文件-->
    <properties resource="jdbc.properties"/>

    <!--配置LOG4J-->
    <settings>
    <setting name="logImpl" value="log4j"/>
    </settings>

    <!--起别名-->
    <typeAliases>
    <typeAlias type="bean.Student" alias="student"/>
    <!--<package name="bean"/>-->
    </typeAliases>

    <!--配置数据库环境,可以多个环境,default指定哪个-->
    <environments default="mysql">
    <!--id属性唯一标识-->
    <environment id="mysql">
    <!--事务管理,type属性,默认JDBC事务-->
    <transactionManager type="JDBC"></transactionManager>
    <!--数据源信息 type属性连接池-->
    <dataSource type="POOLED">
    <!--property获取数据库连接的配置信息-->
    <property name="driver" value="${driver}"/>
    <property name="url" value="${url}"/>
    <property name="username" value="${username}"/>
    <property name="password" value="${password}"/>
    </dataSource>
    </environment>
    </environments>

    <!--引入映射配置文件-->
    <mappers>
    <!--mapper引入指定的映射配置 resource属性执行的映射配置文件的名称-->
    <mapper resource="StudentMapper.xml"/>
    </mappers>
    </configuration>
  • StudentMapper.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    <?xml version="1.0" encoding="UTF-8" ?>
    <!DOCTYPE mapper
    PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

    <mapper namespace="StudentMapper">
    <select id="selectAll" resultType="student">
    SELECT * FROM student
    </select>

    <select id="selectById" resultType="student" parameterType="int">
    SELECT * FROM student WHERE id = #{id}
    </select>

    <insert id="insert" parameterType="student">
    INSERT INTO student VALUES (#{id},#{name},#{age})
    </insert>

    <update id="update" parameterType="student">
    UPDATE student SET name = #{name}, age = #{age} WHERE id = #{id}
    </update>

    <delete id="delete" parameterType="student">
    DELETE FROM student WHERE id = #{id}
    </delete>

    </mapper>
  • 控制层测试代码:根据 id 查询

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    @Test
    public void selectById() throws Exception{
    //1.加载核心配置文件
    InputStream is = Resources.getResourceAsStream("MyBatisConfig.xml");

    //2.获取SqlSession工厂对象
    SqlSessionFactory ssf = new SqlSessionFactoryBuilder().build(is);

    //3.通过工厂对象获取SqlSession对象
    SqlSession sqlSession = ssf.openSession();

    //4.执行映射配置文件中的sql语句,并接收结果
    Student stu = sqlSession.selectOne("StudentMapper.selectById", 3);

    //5.处理结果
    System.out.println(stu);

    //6.释放资源
    sqlSession.close();
    is.close();
    }
  • 控制层测试代码:新增功能

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    @Test
    public void insert() throws Exception{
    //1.加载核心配置文件
    //2.获取SqlSession工厂对象
    //3.通过工厂对象获取SqlSession对象
    SqlSession sqlSession = sqlSessionFactory.openSession(true);

    //4.执行映射配置文件中的sql语句,并接收结果
    Student stu = new Student(5, "周七", 27);
    int result = sqlSession.insert("StudentMapper.insert", stu);

    //5.提交事务
    //sqlSession.commit();

    //6.处理结果
    System.out.println(result);

    //7.释放资源
    sqlSession.close();
    is.close();
    }

批量操作

三种方式实现批量操作:

  • 标签属性:这种方式属于全局批量

    1
    2
    3
    <settings>
    <setting name="defaultExecutorType" value="BATCH"/>
    </settings>

    defaultExecutorType:配置默认的执行器

    • SIMPLE 就是普通的执行器(默认,每次执行都要重新设置参数)
    • REUSE 执行器会重用预处理语句(只预设置一次参数,多次执行)
    • BATCH 执行器不仅重用语句还会执行批量更新(只针对修改操作
  • SqlSession 会话内批量操作:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    public void testBatch() throws IOException{
    SqlSessionFactory sqlSessionFactory = getSqlSessionFactory();

    // 可以执行批量操作的sqlSession
    SqlSession openSession = sqlSessionFactory.openSession(ExecutorType.BATCH);
    long start = System.currentTimeMillis();
    try{
    EmployeeMapper mapper = openSession.getMapper(EmployeeMapper.class);
    for (int i = 0; i < 10000; i++) {
    mapper.addEmp(new Employee(UUID.randomUUID().toString().substring(0, 5), "b", "1"));
    }
    openSession.commit();
    long end = System.currentTimeMillis();
    // 批量:(预编译sql一次==>设置参数===>10000次===>执行1次(类似管道))
    // 非批量:(预编译sql=设置参数=执行)==》10000 耗时更多
    System.out.println("执行时长:" + (end - start));
    }finally{
    openSession.close();
    }
    }
  • Spring 配置文件方式(applicationContext.xml):

    1
    2
    3
    4
    5
    <!--配置一个可以进行批量执行的sqlSession  -->
    <bean id="sqlSession" class="org.mybatis.spring.SqlSessionTemplate">
    <constructor-arg name="sqlSessionFactory" ref="sqlSessionFactoryBean"/>
    <constructor-arg name="executorType" value="BATCH"/>
    </bean>
    1
    2
    @Autowired
    private SqlSession sqlSession;

代理开发

代理规则

分层思想:控制层(controller)、业务层(service)、持久层(dao)

调用流程:

传统方式实现 DAO 层,需要写接口和实现类。采用 Mybatis 的代理开发方式实现 DAO 层的开发,只需要编写 Mapper 接口(相当于 Dao 接口),由 Mybatis 框架根据接口定义创建接口的动态代理对象

接口开发方式:

  1. 定义接口
  2. 操作数据库,MyBatis 框架根据接口,通过动态代理的方式生成代理对象,负责数据库的操作

Mapper 接口开发需要遵循以下规范:

  • Mapper.xml 文件中的 namespace 与 DAO 层 mapper 接口的全类名相同

  • Mapper.xml 文件中的增删改查标签的id属性和 DAO 层 Mapper 接口方法名相同

  • Mapper.xml 文件中的增删改查标签的 parameterType 属性和 DAO 层 Mapper 接口方法的参数相同

  • Mapper.xml 文件中的增删改查标签的 resultType 属性和 DAO 层 Mapper 接口方法的返回值相同


实现原理

通过动态代理开发模式,只编写一个接口不写实现类,通过 getMapper() 方法最终获取到 MapperProxy 代理对象,而这个代理对象是 MyBatis 使用了 JDK 的动态代理技术生成的

动态代理实现类对象在执行方法时最终调用了 MapperMethod.execute() 方法,这个方法中通过 switch case 语句根据操作类型来判断是新增、修改、删除、查询操作,最后一步回到了 MyBatis 最原生的 SqlSession 方式来执行增删改查

  • 代码实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    public Student selectById(Integer id) {
    Student stu = null;
    SqlSession sqlSession = null;
    InputStream is = null;
    try{
    //1.加载核心配置文件
    is = Resources.getResourceAsStream("MyBatisConfig.xml");

    //2.获取SqlSession工厂对象
    SqlSessionFactory s = new SqlSessionFactoryBuilder().build(is);

    //3.通过工厂对象获取SqlSession对象
    sqlSession = s.openSession(true);

    //4.获取StudentMapper接口的实现类对象
    StudentMapper mapper = sqlSession.getMapper(StudentMapper.class);

    //5.通过实现类对象调用方法,接收结果
    stu = mapper.selectById(id);
    } catch (Exception e) {
    e.getMessage();
    } finally {
    //6.释放资源
    if(sqlSession != null) {
    sqlSession.close();
    }
    if(is != null) {
    try {
    is.close();
    } catch (IOException e) {
    e.printStackTrace();
    }
    }
    }
    //7.返回结果
    return stu;
    }

结果映射

相关标签

:返回结果映射对象类型,和对应方法的返回值类型保持一致,但是如果返回值是 List 则和其泛型保持一致

:返回一条记录的 Map,key 是列名,value 是对应的值,用来配置字段和对象属性的映射关系标签,结果映射(和 resultType 二选一)

  • id 属性:唯一标识
  • type 属性:实体对象类型
  • autoMapping 属性:结果自动映射

内的核心配置文件标签:

  • :配置主键映射关系标签

  • :配置非主键映射关系标签

    • column 属性:表中字段名称
    • property 属性: 实体对象变量名称
  • :配置被包含单个对象的映射关系标签,嵌套封装结果集(多对一、一对一)

    • property 属性:被包含对象的变量名,要进行映射的属性名
    • javaType 属性:被包含对象的数据类型,要进行映射的属性的类型(Java 中的 Bean 类)
    • select 属性:加载复杂类型属性的映射语句的 ID,会从 column 属性指定的列中检索数据,作为参数传递给目标 select 语句
  • :配置被包含集合对象的映射关系标签,嵌套封装结果集(一对多、多对多)

    • property 属性:被包含集合对象的变量名
    • ofType 属性:集合中保存的对象数据类型
  • :鉴别器,用来判断某列的值,根据得到某列的不同值做出不同自定义的封装行为

自定义封装规则可以将数据库中比较复杂的数据类型映射为 JavaBean 中的属性


嵌套查询

子查询:

1
2
3
4
5
6
public class Blog {
private int id;
private String msg;
private Author author;
// set + get
}
1
2
3
4
5
6
7
8
9
10
11
<resultMap id="blogResult" type="Blog" autoMapping = "true">
<association property="author" column="author_id" javaType="Author" select="selectAuthor"/>
</resultMap>

<select id="selectBlog" resultMap="blogResult">
SELECT * FROM BLOG WHERE ID = #{id}
</select>

<select id="selectAuthor" resultType="Author">
SELECT * FROM AUTHOR WHERE ID = #{id}
</select>

循环引用:通过缓存解决

1
2
3
4
5
6
<resultMap id="blogResult" type="Blog" autoMapping = "true">
<id column="id" property="id"/>
<collection property="comment" ofType="Comment">
<association property="blog" javaType="Blog" resultMap="blogResult"/><!--y-->
</collection>
</resultMap

多表查询

一对一

一对一实现:

  • 数据准备

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    CREATE TABLE person(
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(20),
    age INT
    );
    INSERT INTO person VALUES (NULL,'张三',23),(NULL,'李四',24),(NULL,'王五',25);

    CREATE TABLE card(
    id INT PRIMARY KEY AUTO_INCREMENT,
    number VARCHAR(30),
    pid INT,
    CONSTRAINT cp_fk FOREIGN KEY (pid) REFERENCES person(id)
    );
    INSERT INTO card VALUES (NULL,'12345',1),(NULL,'23456',2),(NULL,'34567',3);
  • bean 类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public class Card {
    private Integer id; //主键id
    private String number; //身份证号
    private Person p; //所属人的对象
    ......
    }

    public class Person {
    private Integer id; //主键id
    private String name; //人的姓名
    private Integer age; //人的年龄
    }
  • 配置文件 OneToOneMapper.xml,MyBatisConfig.xml 需要引入(可以把 bean 包下起别名)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    <?xml version="1.0" encoding="UTF-8" ?>
    <!DOCTYPE mapper
    PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

    <mapper namespace="OneToOneMapper">

    <!--配置字段和实体对象属性的映射关系-->
    <resultMap id="oneToOne" type="card">
    <!--column 表中字段名称,property 实体对象变量名称-->
    <id column="cid" property="id" />
    <result column="number" property="number" />
    <!--
    association:配置被包含对象的映射关系
    property:被包含对象的变量名
    javaType:被包含对象的数据类型
    -->
    <association property="p" javaType="bean.Person">
    <id column="pid" property="id" />
    <result column="name" property="name" />
    <result column="age" property="age" />
    </association>
    </resultMap>

    <select id="selectAll" resultMap="oneToOne"> <!--SQL-->
    SELECT c.id cid,number,pid,NAME,age FROM card c,person p WHERE c.pid=p.id
    </select>
    </mapper>
  • 核心配置文件 MyBatisConfig.xml

    1
    2
    3
    4
    5
    6
    <!-- mappers引入映射配置文件 -->
    <mappers>
    <mapper resource="one_to_one/OneToOneMapper.xml"/>
    <mapper resource="one_to_many/OneToManyMapper.xml"/>
    <mapper resource="many_to_many/ManyToManyMapper.xml"/>
    </mappers>
  • 测试类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    public class Test01 {
    @Test
    public void selectAll() throws Exception{
    //1.加载核心配置文件
    InputStream is = Resources.getResourceAsStream("MyBatisConfig.xml");

    //2.获取SqlSession工厂对象
    SqlSessionFactory ssf = new SqlSessionFactoryBuilder().build(is);

    //3.通过工厂对象获取SqlSession对象
    SqlSession sqlSession = ssf.openSession(true);

    //4.获取OneToOneMapper接口的实现类对象
    OneToOneMapper mapper = sqlSession.getMapper(OneToOneMapper.class);

    //5.调用实现类的方法,接收结果
    List<Card> list = mapper.selectAll();

    //6.处理结果
    for (Card c : list) {
    System.out.println(c);
    }

    //7.释放资源
    sqlSession.close();
    is.close();
    }
    }

一对多

一对多实现:

  • 数据准备

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    CREATE TABLE classes(
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(20)
    );
    INSERT INTO classes VALUES (NULL,'程序一班'),(NULL,'程序二班')

    CREATE TABLE student(
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(30),
    age INT,
    cid INT,
    CONSTRAINT cs_fk FOREIGN KEY (cid) REFERENCES classes(id)
    );
    INSERT INTO student VALUES (NULL,'张三',23,1),(NULL,'李四',24,1),(NULL,'王五',25,2);
  • bean 类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class Classes {
    private Integer id; //主键id
    private String name; //班级名称
    private List<Student> students; //班级中所有学生对象
    ........
    }
    public class Student {
    private Integer id; //主键id
    private String name; //学生姓名
    private Integer age; //学生年龄
    }
  • 映射配置文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <mapper namespace="OneToManyMapper">
    <resultMap id="oneToMany" type="bean.Classes">
    <id column="cid" property="id"/>
    <result column="cname" property="name"/>

    <!--collection:配置被包含的集合对象映射关系-->
    <collection property="students" ofType="bean.Student">
    <id column="sid" property="id"/>
    <result column="sname" property="name"/>
    <result column="sage" property="age"/>
    </collection>
    </resultMap>
    <select id="selectAll" resultMap="oneToMany"> <!--SQL-->
    SELECT c.id cid,c.name cname,s.id sid,s.name sname,s.age sage FROM classes c,student s WHERE c.id=s.cid
    </select>
    </mapper>
  • 代码实现片段

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    //4.获取OneToManyMapper接口的实现类对象
    OneToManyMapper mapper = sqlSession.getMapper(OneToManyMapper.class);

    //5.调用实现类的方法,接收结果
    List<Classes> classes = mapper.selectAll();

    //6.处理结果
    for (Classes cls : classes) {
    System.out.println(cls.getId() + "," + cls.getName());
    List<Student> students = cls.getStudents();
    for (Student student : students) {
    System.out.println("\t" + student);
    }
    }

多对多

学生课程例子,中间表不需要 bean 实体类

  • 数据准备

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    CREATE TABLE course(
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(20)
    );
    INSERT INTO course VALUES (NULL,'语文'),(NULL,'数学');

    CREATE TABLE stu_cr(
    id INT PRIMARY KEY AUTO_INCREMENT,
    sid INT,
    cid INT,
    CONSTRAINT sc_fk1 FOREIGN KEY (sid) REFERENCES student(id),
    CONSTRAINT sc_fk2 FOREIGN KEY (cid) REFERENCES course(id)
    );
    INSERT INTO stu_cr VALUES (NULL,1,1),(NULL,1,2),(NULL,2,1),(NULL,2,2);
  • bean类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public class Student {
    private Integer id; //主键id
    private String name; //学生姓名
    private Integer age; //学生年龄
    private List<Course> courses; // 学生所选择的课程集合
    }
    public class Course {
    private Integer id; //主键id
    private String name; //课程名称
    }
  • 配置文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    <mapper namespace="ManyToManyMapper">
    <resultMap id="manyToMany" type="Bean.Student">
    <id column="sid" property="id"/>
    <result column="sname" property="name"/>
    <result column="sage" property="age"/>

    <collection property="courses" ofType="Bean.Course">
    <id column="cid" property="id"/>
    <result column="cname" property="name"/>
    </collection>
    </resultMap>
    <select id="selectAll" resultMap="manyToMany"> <!--SQL-->
    SELECT sc.sid,s.name sname,s.age sage,sc.cid,c.name cname FROM student s,course c,stu_cr sc WHERE sc.sid=s.id AND sc.cid=c.id
    </select>
    </mapper>

鉴别器

需求:如果查询结果是女性,则把部门信息查询出来,否则不查询 ;如果是男性,把 last_name 这一列的值赋值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!-- 
column:指定要判断的列名
javaType:列值对应的java类型
-->
<discriminator javaType="string" column="gender">
<!-- 女生 -->
<!-- resultType不可缺少,也可以使用resutlMap -->
<case value="0" resultType="com.bean.Employee">
<association property="dept"
select="com.dao.DepartmentMapper.getDeptById"
column="d_id">
</association>
</case>
<!-- 男生 -->
<case value="1" resultType="com.bean.Employee">
<id column="id" property="id"/>
<result column="last_name" property="lastName"/>
<result column="gender" property="gender"/>
</case>
</discriminator>

延迟加载

两种加载

立即加载:只要调用方法,马上发起查询

延迟加载:在需要用到数据时才进行加载,不需要用到数据时就不加载数据,延迟加载也称懒加载

优点: 先从单表查询,需要时再从关联表去关联查询,提高数据库性能,因为查询单表要比关联查询多张表速度要快,节省资源

坏处:只有当需要用到数据时,才会进行数据库查询,这样在大批量数据查询时,查询工作也要消耗时间,所以可能造成用户等待时间变长,造成用户体验下降

核心配置文件:

标签名 描述 默认值
lazyLoadingEnabled 延迟加载的全局开关。当开启时,所有关联对象都会延迟加载,特定关联关系中可通过设置 fetchType 属性来覆盖该项的开关状态。 false
aggressiveLazyLoading 开启时,任一方法的调用都会加载该对象的所有延迟加载属性。否则每个延迟加载属性会按需加载(参考 lazyLoadTriggerMethods) false
1
2
3
4
<settings> 
<setting name="lazyLoadingEnabled" value="true"/>
<setting name="aggressiveLazyLoading" value="false"/>
</settings>

assocation

分布查询:先按照身份 id 查询所属人的 id、然后根据所属人的 id 去查询人的全部信息,这就是分步查询

  • 映射配置文件 OneToOneMapper.xml

    一对一映射:

    • column 属性表示给要调用的其它的 select 标签传入的参数
    • select 属性表示调用其它的 select 标签
    • fetchType=”lazy” 表示延迟加载(局部配置,只有配置了这个的地方才会延迟加载)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    <mapper namespace="OneToOneMapper">
    <!--配置字段和实体对象属性的映射关系-->
    <resultMap id="oneToOne" type="card">
    <id column="id" property="id" />
    <result column="number" property="number" />
    <association property="p" javaType="bean.Person"
    column="pid"
    select="one_to_one.PersonMapper.findPersonByid"
    fetchType="lazy">
    <!--需要配置新的映射文件-->
    </association>
    </resultMap>

    <select id="selectAll" resultMap="oneToOne">
    SELECT * FROM card <!--查询全部,负责根据条件直接全部加载-->
    </select>
    </mapper>
  • PersonMapper.xml

    1
    2
    3
    4
    5
    <mapper namespace="one_to_one.PersonMapper">
    <select id="findPersonByid" parameterType="int" resultType="person">
    SELECT * FROM person WHERE id=#{pid}
    </select>
    </mapper>
  • PersonMapper.java

    1
    2
    3
    public interface PersonMapper {
    User findPersonByid(int id);
    }
  • 测试文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    public class Test01 {
    @Test
    public void selectAll() throws Exception{
    InputStream is = Resources.getResourceAsStream("MyBatisConfig.xml");
    SqlSessionFactory ssf = new SqlSessionFactoryBuilder().build(is);
    SqlSession sqlSession = ssf.openSession(true);
    OneToOneMapper mapper = sqlSession.getMapper(OneToOneMapper.class);
    // 调用实现类的方法,接收结果
    List<Card> list = mapper.selectAll();

    // 不能遍历,遍历就是相当于使用了该数据,需要加载,不遍历就是没有使用。

    // 释放资源
    sqlSession.close();
    is.close();
    }
    }

collection

同样在一对多关系配置的 结点中配置延迟加载策略, 结点中也有 select 属性和 column 属性

  • 映射配置文件 OneToManyMapper.xml

    一对多映射:

    • column 是用于指定使用哪个字段的值作为条件查询
    • select 是用于指定查询账户的唯一标识(账户的 dao 全限定类名加上方法名称)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    <mapper namespace="OneToManyMapper">
    <resultMap id="oneToMany" type="bean.Classes">
    <id column="id" property="id"/>
    <result column="name" property="name"/>

    <!--collection:配置被包含的集合对象映射关系-->
    <collection property="students" ofType="bean.Student"
    column="id"
    select="one_to_one.StudentMapper.findStudentByCid">
    </collection>
    </resultMap>
    <select id="selectAll" resultMap="oneToMany">
    SELECT * FROM classes
    </select>
    </mapper>
  • StudentMapper.xml

    1
    2
    3
    4
    5
    <mapper namespace="one_to_one.StudentMapper">
    <select id="findPersonByCid" parameterType="int" resultType="student">
    SELECT * FROM person WHERE cid=#{id}
    </select>
    </mapper>

注解开发

单表操作

注解可以简化开发操作,省略映射配置文件的编写

常用注解:

  • @Select(“查询的 SQL 语句”):执行查询操作注解
  • @Insert(“插入的 SQL 语句”):执行新增操作注解
  • @Update(“修改的 SQL 语句”):执行修改操作注解
  • @Delete(“删除的 SQL 语句”):执行删除操作注解

参数注解:

  • @Param:当 SQL 语句需要多个(大于1)参数时,用来指定参数的对应规则

核心配置文件配置映射关系:

1
2
3
4
5
6
7
<mappers>
<package name="使用了注解的Mapper接口所在包"/>
</mappers>
<!--或者-->
<mappers>
<mapper class="包名.Mapper名"></mapper>
</mappers>

基本增删改查:

  • 创建 Mapper 接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    package mapper;
    public interface StudentMapper {
    //查询全部
    @Select("SELECT * FROM student")
    public abstract List<Student> selectAll();

    //新增数据
    @Insert("INSERT INTO student VALUES (#{id},#{name},#{age})")
    public abstract Integer insert(Student student);

    //修改操作
    @Update("UPDATE student SET name=#{name},age=#{age} WHERE id=#{id}")
    public abstract Integer update(Student student);

    //删除操作
    @Delete("DELETE FROM student WHERE id=#{id}")
    public abstract Integer delete(Integer id);

    }
  • 修改 MyBatis 的核心配置文件

    1
    2
    3
    <mappers>
    <package name="mapper"/>
    </mappers>
  • bean类

    1
    2
    3
    4
    5
    public class Student {
    private Integer id;
    private String name;
    private Integer age;
    }
  • 测试类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    @Test
    public void selectAll() throws Exception{
    //1.加载核心配置文件
    InputStream is = Resources.getResourceAsStream("MyBatisConfig.xml");

    //2.获取SqlSession工厂对象
    SqlSessionFactory ssf = new SqlSessionFactoryBuilder().build(is);

    //3.通过工厂对象获取SqlSession对象
    SqlSession sqlSession = ssf.openSession(true);

    //4.获取StudentMapper接口的实现类对象
    StudentMapper mapper = sqlSession.getMapper(StudentMapper.class);

    //5.调用实现类对象中的方法,接收结果
    List<Student> list = mapper.selectAll();

    //6.处理结果
    for (Student student : list) {
    System.out.println(student);
    }

    //7.释放资源
    sqlSession.close();
    is.close();
    }

多表操作

相关注解

实现复杂关系映射之前我们可以在映射文件中通过配置 来实现,使用注解开发后,可以使用 @Results 注解,@Result 注解,@One 注解,@Many 注解组合完成复杂关系的配置

注解 说明
@Results 代替 标签,注解中使用单个 @Result 注解或者 @Result 集合
使用格式:@Results({ @Result(), @Result() })或@Results({ @Result() })
@Result 代替< id> 和 标签,@Result 中属性介绍:
column:数据库的列名 property:封装类的变量名
one:需要使用 @One 注解(@Result(one = @One))
Many:需要使用 @Many 注解(@Result(many= @Many))
@One(一对一) 代替 标签,多表查询的关键,用来指定子查询返回单一对象
select:指定调用 Mapper 接口中的某个方法
使用格式:@Result(column=””, property=””, one=@One(select=””))
@Many(多对一) 代替 标签,多表查询的关键,用来指定子查询返回对象集合
select:指定调用 Mapper 接口中的某个方法
使用格式:@Result(column=””, property=””, many=@Many(select=””))

一对一

身份证对人

  • PersonMapper 接口

    1
    2
    3
    4
    5
    public interface PersonMapper {
    //根据id查询
    @Select("SELECT * FROM person WHERE id=#{id}")
    public abstract Person selectById(Integer id);
    }
  • CardMapper接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    public interface CardMapper {
    //查询全部
    @Select("SELECT * FROM card")
    @Results({
    @Result(column = "id",property = "id"),
    @Result(column = "number",property = "number"),
    @Result(
    property = "p", // 被包含对象的变量名
    javaType = Person.class, // 被包含对象的实际数据类型
    column = "pid", // 根据查询出的card表中的pid字段来查询person表
    /*
    one、@One 一对一固定写法
    select属性:指定调用哪个接口中的哪个方法
    */
    one = @One(select = "one_to_one.PersonMapper.selectById")
    )
    })
    public abstract List<Card> selectAll();
    }
  • 测试类(详细代码参考单表操作)

    1
    2
    3
    4
    5
    6
    7
    8
    //1.加载核心配置文件
    //2.获取SqlSession工厂对象
    //3.通过工厂对象获取SqlSession对象

    //4.获取StudentMapper接口的实现类对象
    CardMapper mapper = sqlSession.getMapper(CardMapper.class);
    //5.调用实现类对象中的方法,接收结果
    List<Card> list = mapper.selectAll();

一对多

班级和学生

  • StudentMapper接口

    1
    2
    3
    4
    5
    public interface StudentMapper {
    //根据cid查询student表 cid是外键约束列
    @Select("SELECT * FROM student WHERE cid=#{cid}")
    public abstract List<Student> selectByCid(Integer cid);
    }
  • ClassesMapper接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public interface ClassesMapper {
    //查询全部
    @Select("SELECT * FROM classes")
    @Results({
    @Result(column = "id", property = "id"),
    @Result(column = "name", property = "name"),
    @Result(
    property = "students", //被包含对象的变量名
    javaType = List.class, //被包含对象的实际数据类型
    column = "id", //根据id字段查询student表
    many = @Many(select = "one_to_many.StudentMapper.selectByCid")
    )
    })
    public abstract List<Classes> selectAll();
    }
  • 测试类

    1
    2
    3
    4
    //4.获取StudentMapper接口的实现类对象
    ClassesMapper mapper = sqlSession.getMapper(ClassesMapper.class);
    //5.调用实现类对象中的方法,接收结果
    List<Classes> classes = mapper.selectAll();

多对多

学生和课程

  • SQL 查询语句

    1
    2
    SELECT DISTINCT s.id,s.name,s.age FROM student s,stu_cr sc WHERE sc.sid=s.id
    SELECT c.id,c.name FROM stu_cr sc,course c WHERE sc.cid=c.id AND sc.sid=#{id}
  • CourseMapper 接口

    1
    2
    3
    4
    5
    public interface CourseMapper {
    //根据学生id查询所选课程
    @Select("SELECT c.id,c.name FROM stu_cr sc,course c WHERE sc.cid=c.id AND sc.sid=#{id}")
    public abstract List<Course> selectBySid(Integer id);
    }
  • StudentMapper 接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    public interface StudentMapper {
    //查询全部
    @Select("SELECT DISTINCT s.id,s.name,s.age FROM student s,stu_cr sc WHERE sc.sid=s.id")
    @Results({
    @Result(column = "id",property = "id"),
    @Result(column = "name",property = "name"),
    @Result(column = "age",property = "age"),
    @Result(
    property = "courses", //被包含对象的变量名
    javaType = List.class, //被包含对象的实际数据类型
    column = "id", //根据查询出的student表中的id字段查询中间表和课程表
    many = @Many(select = "many_to_many.CourseMapper.selectBySid")
    )
    })
    public abstract List<Student> selectAll();
    }

  • 测试类

    1
    2
    3
    4
    //4.获取StudentMapper接口的实现类对象
    StudentMapper mapper = sqlSession.getMapper(StudentMapper.class);
    //5.调用实现类对象中的方法,接收结果
    List<Student> students = mapper.selectAll();

缓存机制

缓存概述

缓存:缓存就是一块内存空间,保存临时数据

作用:将数据源(数据库或者文件)中的数据读取出来存放到缓存中,再次获取时直接从缓存中获取,可以减少和数据库交互的次数,提升程序的性能

缓存适用:

  • 适用于缓存的:经常查询但不经常修改的,数据的正确与否对最终结果影响不大的
  • 不适用缓存的:经常改变的数据 , 敏感数据(例如:股市的牌价,银行的汇率,银行卡里面的钱)等等

缓存类别:

  • 一级缓存:SqlSession 级别的缓存,又叫本地会话缓存,自带的(不需要配置),一级缓存的生命周期与 SqlSession 一致。在操作数据库时需要构造 SqlSession 对象,在对象中有一个数据结构(HashMap)用于存储缓存数据,不同的 SqlSession 之间的缓存数据区域是互相不影响的
  • 二级缓存:mapper(namespace)级别的缓存,二级缓存的使用,需要手动开启(需要配置)。多个 SqlSession 去操作同一个 Mapper 的 SQL 可以共用二级缓存,二级缓存是跨 SqlSession 的

开启缓存:配置核心配置文件中 标签

  • cacheEnabled:true 表示全局性地开启所有映射器配置文件中已配置的任何缓存,默认 true

参考文章:https://www.cnblogs.com/ysocean/p/7342498.html


一级缓存

一级缓存是 SqlSession 级别的缓存

工作流程:第一次发起查询用户 id 为 1 的用户信息,先去找缓存中是否有 id 为 1 的用户信息,如果没有,从数据库查询用户信息,得到用户信息,将用户信息存储到一级缓存中;第二次发起查询用户 id 为 1 的用户信息,先去找缓存中是否有 id 为 1 的用户信息,缓存中有,直接从缓存中获取用户信息。

一级缓存的失效:

  • SqlSession 不同
  • SqlSession 相同,查询条件不同时(还未缓存该数据)
  • SqlSession 相同,手动清除了一级缓存,调用 sqlSession.clearCache()
  • SqlSession 相同,执行 commit 操作或者执行插入、更新、删除,清空 SqlSession 中的一级缓存,这样做的目的为了让缓存中存储的是最新的信息,避免脏读

Spring 整合 MyBatis 后,一级缓存作用:

  • 未开启事务的情况,每次查询 Spring 都会创建新的 SqlSession,因此一级缓存失效
  • 开启事务的情况,Spring 使用 ThreadLocal 获取当前资源绑定同一个 SqlSession,因此此时一级缓存是有效的

测试一级缓存存在

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public void testFirstLevelCache(){
//1. 获取sqlSession对象
SqlSession sqlSession = SqlSessionFactoryUtils.openSession();
//2. 通过sqlSession对象获取UserDao接口的代理对象
UserDao userDao1 = sqlSession.getMapper(UserDao.class);
//3. 调用UserDao接口的代理对象的findById方法获取信息
User user1 = userDao1.findById(1);
System.out.println(user1);

//sqlSession.clearCache() 清空缓存

UserDao userDao2 = sqlSession.getMapper(UserDao.class);
User user = userDao.findById(1);
System.out.println(user2);

//4.测试两次结果是否一样
System.out.println(user1 == user2);//true

//5. 提交事务关闭资源
SqlSessionFactoryUtils.commitAndClose(sqlSession);
}

二级缓存

基本介绍

二级缓存是 mapper 的缓存,只要是同一个命名空间(namespace)的 SqlSession 就共享二级缓存的内容,并且可以操作二级缓存

作用:作用范围是整个应用,可以跨线程使用,适合缓存一些修改较少的数据

工作流程:一个会话查询数据,这个数据就会被放在当前会话的一级缓存中,如果会话关闭或提交一级缓存中的数据会保存到二级缓存

二级缓存的基本使用:

  1. 在 MyBatisConfig.xml 文件开启二级缓存,cacheEnabled 默认值为 true,所以这一步可以省略不配置

    1
    2
    3
    4
    <!--配置开启二级缓存-->
    <settings>
    <setting name="cacheEnabled" value="true"/>
    </settings>
  2. 配置 Mapper 映射文件

    <cache> 标签表示当前这个 mapper 映射将使用二级缓存,区分的标准就看 mapper 的 namespace 值

    1
    2
    3
    4
    5
    <mapper namespace="dao.UserDao">
    <!--开启user支持二级缓存-->
    <cache eviction="FIFO" flushInterval="6000" readOnly="" size="1024"/>
    <cache></cache> <!--则表示所有属性使用默认值-->
    </mapper>

    eviction(清除策略):

    • LRU – 最近最少使用:移除最长时间不被使用的对象,默认
    • FIFO – 先进先出:按对象进入缓存的顺序来移除它们
    • SOFT – 软引用:基于垃圾回收器状态和软引用规则移除对象
    • WEAK – 弱引用:更积极地基于垃圾收集器状态和弱引用规则移除对象

    flushInterval(刷新间隔):可以设置为任意的正整数, 默认情况是不设置,也就是没有刷新间隔,缓存仅仅会在调用语句时刷新

    size(引用数目):缓存存放多少元素,默认值是 1024

    readOnly(只读):可以被设置为 true 或 false

    • 只读的缓存会给所有调用者返回缓存对象的相同实例,因此这些对象不能被修改,促进了性能提升
    • 可读写的缓存会(通过序列化)返回缓存对象的拷贝, 速度上会慢一些,但是更安全,因此默认值是 false

    type:指定自定义缓存的全类名,实现 Cache 接口即可

  3. 要进行二级缓存的类必须实现 java.io.Serializable 接口,可以使用序列化方式来保存对象。

    1
    public class User implements Serializable{}

相关属性

  1. select 标签的 useCache 属性

    映射文件中的 <select> 标签中设置 useCache="true" 代表当前 statement 要使用二级缓存(默认)

    注意:如果每次查询都需要最新的数据 sql,要设置成 useCache=false,禁用二级缓存

    1
    2
    3
    <select id="findAll" resultType="user" useCache="true">
    select * from user
    </select>
  2. 每个增删改标签都有 flushCache 属性,默认为 true,代表在执行增删改之后就会清除一、二级缓存,保证缓存的一致性;而查询标签默认值为 false,所以查询不会清空缓存

  3. localCacheScope:本地缓存作用域, 中的配置项,默认值为 SESSION,当前会话的所有数据保存在会话缓存中,设置为 STATEMENT 禁用一级缓存


源码解析

事务提交二级缓存才生效:DefaultSqlSession 调用 commit() 时会回调 executor.commit()

  • CachingExecutor#query():执行查询方法,查询出的数据会先放入 entriesToAddOnCommit 集合暂存

    1
    2
    3
    4
    5
    6
    7
    8
    // 从二缓存中获取数据,获取不到去一级缓存获取
    List<E> list = (List<E>) tcm.getObject(cache, key);
    if (list == null) {
    // 回调 BaseExecutor#query
    list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
    // 将数据放入 entriesToAddOnCommit 集合暂存,此时还没放入二级缓存
    tcm.putObject(cache, key, list);
    }
  • commit():事务提交,清空一级缓存,放入二级缓存,二级缓存使用 TransactionalCacheManager(tcm)管理

    1
    2
    3
    4
    5
    public void commit(boolean required) throws SQLException {
    // 首先调用 BaseExecutor#commit 方法,【清空一级缓存】
    delegate.commit(required);
    tcm.commit();
    }
  • TransactionalCacheManager#commit:查询出的数据放入二级缓存

    1
    2
    3
    4
    5
    6
    public void commit() {
    // 获取所有的缓存事务,挨着进行提交
    for (TransactionalCache txCache : transactionalCaches.values()) {
    txCache.commit();
    }
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    public void commit() {
    if (clearOnCommit) {
    delegate.clear();
    }
    // 将 entriesToAddOnCommit 中的数据放入二级缓存
    flushPendingEntries();
    // 清空相关集合
    reset();
    }
    1
    2
    3
    4
    5
    6
    private void flushPendingEntries() {
    for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) {
    // 将数据放入二级缓存
    delegate.putObject(entry.getKey(), entry.getValue());
    }
    }

增删改操作会清空缓存:

  • update():CachingExecutor 的更新操作

    1
    2
    3
    4
    5
    public int update(MappedStatement ms, Object parameterObject) throws SQLException {
    flushCacheIfRequired(ms);
    // 回调 BaseExecutor#update 方法,也会清空一级缓存
    return delegate.update(ms, parameterObject);
    }
  • flushCacheIfRequired():判断是否需要清空二级缓存

    1
    2
    3
    4
    5
    6
    7
    8
    private void flushCacheIfRequired(MappedStatement ms) {
    Cache cache = ms.getCache();
    // 判断二级缓存是否存在,然后判断标签的 flushCache 的值,增删改操作的 flushCache 属性默认为 true
    if (cache != null && ms.isFlushCacheRequired()) {
    // 清空二级缓存
    tcm.clear(cache);
    }
    }

自定义

自定义缓存

1
<cache type="com.domain.something.MyCustomCache"/>

type 属性指定的类必须实现 org.apache.ibatis.cache.Cache 接口,且提供一个接受 String 参数作为 id 的构造器

1
2
3
4
5
6
7
8
9
public interface Cache {
String getId();
int getSize();
void putObject(Object key, Object value);
Object getObject(Object key);
boolean hasKey(Object key);
Object removeObject(Object key);
void clear();
}

缓存的配置,只需要在缓存实现中添加公有的 JavaBean 属性,然后通过 cache 元素传递属性值,例如在缓存实现上调用一个名为 setCacheFile(String file) 的方法:

1
2
3
<cache type="com.domain.something.MyCustomCache">
<property name="cacheFile" value="/tmp/my-custom-cache.tmp"/>
</cache>
  • 可以使用所有简单类型作为 JavaBean 属性的类型,MyBatis 会进行转换。
  • 可以使用占位符(如 ${cache.file}),以便替换成在配置文件属性中定义的值

MyBatis 支持在所有属性设置完毕之后,调用一个初始化方法, 如果想要使用这个特性,可以在自定义缓存类里实现 org.apache.ibatis.builder.InitializingObject 接口

1
2
3
public interface InitializingObject {
void initialize() throws Exception;
}

注意:对缓存的配置(如清除策略、可读或可读写等),不能应用于自定义缓存

对某一命名空间的语句,只会使用该命名空间的缓存进行缓存或刷新,在多个命名空间中共享相同的缓存配置和实例,可以使用 cache-ref 元素来引用另一个缓存

1
<cache-ref namespace="com.someone.application.data.SomeMapper"/>

构造语句

动态 SQL

基本介绍

动态 SQL 是 MyBatis 强大特性之一,逻辑复杂时,MyBatis 映射配置文件中,SQL 是动态变化的,所以引入动态 SQL 简化拼装 SQL 的操作

DynamicSQL 包含的标签:

  • if
  • where
  • set
  • choose (when、otherwise)
  • trim
  • foreach

各个标签都可以进行灵活嵌套和组合

OGNL:Object Graphic Navigation Language(对象图导航语言),用于对数据进行访问

参考文章:https://www.cnblogs.com/ysocean/p/7289529.html


where

:条件标签,有动态条件则使用该标签代替 WHERE 关键字,封装查询条件

作用:如果标签返回的内容是以 AND 或 OR 开头的,标签内会剔除掉

表结构:


if

基本格式:

1
2
3
<if test=“条件判断”>
查询条件拼接
</if>

我们根据实体类的不同取值,使用不同的 SQL 语句来进行查询。比如在 id 如果不为空时可以根据 id 查询,如果username 不同空时还要加入用户名作为条件,这种情况在我们的多条件组合查询中经常会碰到。

  • UserMapper.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    <?xml version="1.0" encoding="UTF-8" ?>
    <!DOCTYPE mapper
    PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

    <mapper namespace="mapper.UserMapper">
    <select id="selectCondition" resultType="user" parameterType="user">
    SELECT * FROM user
    <where>
    <if test="id != null ">
    id = #{id}
    </if>
    <if test="username != null ">
    AND username = #{username}
    </if>
    <if test="sex != null ">
    AND sex = #{sex}
    </if>
    </where>
    </select>

    </mapper>
  • MyBatisConfig.xml,引入映射配置文件

    1
    2
    3
    4
    <mappers>
    <!--mapper引入指定的映射配置 resource属性执行的映射配置文件的名称-->
    <mapper resource="UserMapper.xml"/>
    </mappers>
  • DAO 层 Mapper 接口

    1
    2
    3
    4
    public interface UserMapper {
    //多条件查询
    public abstract List<User> selectCondition(Student stu);
    }
  • 实现类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    public class DynamicTest {
    @Test
    public void selectCondition() throws Exception{
    //1.加载核心配置文件
    InputStream is = Resources.getResourceAsStream("MyBatisConfig.xml");

    //2.获取SqlSession工厂对象
    SqlSessionFactory ssf = new SqlSessionFactoryBuilder().build(is);

    //3.通过工厂对象获取SqlSession对象
    SqlSession sqlSession = ssf.openSession(true);

    //4.获取StudentMapper接口的实现类对象
    UserMapper mapper = sqlSession.getMapper(UserMapper.class);

    User user = new User();
    user.setId(2);
    user.setUsername("李四");
    //user.setSex(男); AND 后会自动剔除

    //5.调用实现类的方法,接收结果
    List<Student> list = mapper.selectCondition(user);

    //6.处理结果
    for (User user : list) {
    System.out.println(user);
    }

    //7.释放资源
    sqlSession.close();
    is.close();
    }
    }

set

:进行更新操作的时候,含有 set 关键词,使用该标签

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 根据 id 更新 user 表的数据 -->
<update id="updateUserById" parameterType="com.ys.po.User">
UPDATE user u
<set>
<if test="username != null and username != ''">
u.username = #{username},
</if>
<if test="sex != null and sex != ''">
u.sex = #{sex}
</if>
</set>
WHERE id=#{id}
</update>
  • 如果第一个条件 username 为空,那么 sql 语句为:update user u set u.sex=? where id=?
  • 如果第一个条件不为空,那么 sql 语句为:update user u set u.username = ? ,u.sex = ? where id=?

choose

假如不想用到所有的查询条件,只要查询条件有一个满足即可,使用 choose 标签可以解决此类问题,类似于 Java 的 switch 语句

标签:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<select id="selectUserByChoose" resultType="user" parameterType="user">
SELECT * FROM user
<where>
<choose>
<when test="id !='' and id != null">
id=#{id}
</when>
<when test="username !='' and username != null">
AND username=#{username}
</when>
<otherwise>
AND sex=#{sex}
</otherwise>
</choose>
</where>
</select>

有三个条件,id、username、sex,只能选择一个作为查询条件

  • 如果 id 不为空,那么查询语句为:select * from user where id=?

  • 如果 id 为空,那么看 username 是否为空

    • 如果不为空,那么语句为:select * from user where username=?
    • 如果 username 为空,那么查询语句为 select * from user where sex=?

trim

trim 标记是一个格式化的标记,可以完成 set 或者是 where 标记的功能,自定义字符串截取

  • prefix:给拼串后的整个字符串加一个前缀,trim 标签体中是整个字符串拼串后的结果
  • prefixOverrides:去掉整个字符串前面多余的字符
  • suffix:给拼串后的整个字符串加一个后缀
  • suffixOverrides:去掉整个字符串后面多余的字符

改写 if + where 语句:

1
2
3
4
5
6
7
8
9
10
11
<select id="selectUserByUsernameAndSex" resultType="user" parameterType="com.ys.po.User">
SELECT * FROM user
<trim prefix="where" prefixOverrides="and | or">
<if test="username != null">
AND username=#{username}
</if>
<if test="sex != null">
AND sex=#{sex}
</if>
</trim>
</select>

改写 if + set 语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 根据 id 更新 user 表的数据 -->
<update id="updateUserById" parameterType="com.ys.po.User">
UPDATE user u
<trim prefix="set" suffixOverrides=",">
<if test="username != null and username != ''">
u.username = #{username},
</if>
<if test="sex != null and sex != ''">
u.sex = #{sex},
</if>
</trim>
WHERE id=#{id}
</update>

foreach

基本格式:

1
2
3
4
<foreach>:循环遍历标签。适用于多个参数或者的关系。
<foreach collection=“”open=“”close=“”item=“”separator=“”>
获取参数
</foreach>

属性:

  • collection:参数容器类型, (list-集合, array-数组)
  • open:开始的 SQL 语句
  • close:结束的 SQL 语句
  • item:参数变量名
  • separator:分隔符

需求:循环执行 sql 的拼接操作,SELECT * FROM user WHERE id IN (1,2,5)

  • UserMapper.xml片段

    1
    2
    3
    4
    5
    6
    7
    8
    <select id="selectByIds" resultType="user" parameterType="list">
    SELECT * FROM student
    <where>
    <foreach collection="list" open="id IN(" close=")" item="id" separator=",">
    #{id}
    </foreach>
    </where>
    </select>
  • 测试代码片段

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    //4.获取StudentMapper接口的实现类对象
    UserMapper mapper = sqlSession.getMapper(UserMapper.class);

    List<Integer> ids = new ArrayList<>();
    Collections.addAll(list, 1, 2);
    //5.调用实现类的方法,接收结果
    List<User> list = mapper.selectByIds(ids);

    for (User user : list) {
    System.out.println(user);
    }

SQL片段

将一些重复性的 SQL 语句进行抽取,以达到复用的效果

格式:

1
2
<sql id=“片段唯一标识”>抽取的SQL语句</sql>		<!--抽取标签-->
<include refid=“片段唯一标识”/> <!--引入标签-->

使用:

1
2
3
4
5
6
7
8
9
10
<sql id="select">SELECT * FROM user</sql>

<select id="selectByIds" resultType="user" parameterType="list">
<include refid="select"/>
<where>
<foreach collection="list" open="id IN(" close=")" item="id" separator=",">
#{id}
</foreach>
</where>
</select>

逆向工程

MyBatis 逆向工程,可以针对单表自动生成 MyBatis 执行所需要的代码(mapper.java、mapper.xml、pojo…)

generatorConfig.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE generatorConfiguration
PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN"
"http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd">

<generatorConfiguration>
<context id="testTables" targetRuntime="MyBatis3">
<commentGenerator>
<!-- 是否去除自动生成的注释 true:是 : false:否 -->
<property name="suppressAllComments" value="true" />
</commentGenerator>
<!--数据库连接的信息:驱动类、连接地址、用户名、密码 -->
<jdbcConnection driverClass="com.mysql.jdbc.Driver"
connectionURL="jdbc:mysql://localhost:3306/mybatisrelation" userId="root"
password="root">
</jdbcConnection>

<!-- 默认false,把JDBC DECIMAL 和 NUMERIC 类型解析为 Integer,为 true时把JDBC DECIMAL和NUMERIC类型解析为java.math.BigDecimal -->
<javaTypeResolver>
<property name="forceBigDecimals" value="false" />
</javaTypeResolver>

<!-- targetProject:生成PO类的位置!! -->
<javaModelGenerator targetPackage="com.ys.po"
targetProject=".\src">
<!-- enableSubPackages:是否让schema作为包的后缀 -->
<property name="enableSubPackages" value="false" />
<!-- 从数据库返回的值被清理前后的空格 -->
<property name="trimStrings" value="true" />
</javaModelGenerator>
<!-- targetProject:mapper映射文件生成的位置!! -->
<sqlMapGenerator targetPackage="com.ys.mapper"
targetProject=".\src">
<property name="enableSubPackages" value="false" />
</sqlMapGenerator>
<!-- targetPackage:mapper接口生成的位置,重要!! -->
<javaClientGenerator type="XMLMAPPER"
targetPackage="com.ys.mapper"
targetProject=".\src">
<property name="enableSubPackages" value="false" />
</javaClientGenerator>
<!-- 指定数据库表,要生成哪些表,就写哪些表,要和数据库中对应,不能写错! -->
<table tableName="items"></table>
<table tableName="orders"></table>
<table tableName="orderdetail"></table>
<table tableName="user"></table>
</context>
</generatorConfiguration>

生成代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void testGenerator() throws Exception{
List<String> warnings = new ArrayList<String>();
boolean overwrite = true;
//指向逆向工程配置文件
File configFile = new File(GeneratorTest.class.
getResource("/generatorConfig.xml").getFile());
ConfigurationParser cp = new ConfigurationParser(warnings);
Configuration config = cp.parseConfiguration(configFile);
DefaultShellCallback callback = new DefaultShellCallback(overwrite);
MyBatisGenerator myBatisGenerator = new MyBatisGenerator(config,
callback, warnings);
myBatisGenerator.generate(null);

}

参考文章:https://www.cnblogs.com/ysocean/p/7360409.html


构建 SQL

基础语法

MyBatis 提供了 org.apache.ibatis.jdbc.SQL 功能类,专门用于构建 SQL 语句

方法 说明
SELECT(String… columns) 根据字段拼接查询语句
FROM(String… tables) 根据表名拼接语句
WHERE(String… conditions) 根据条件拼接语句
INSERT_INTO(String tableName) 根据表名拼接新增语句
INTO_VALUES(String… values) 根据值拼接新增语句
UPDATE(String table) 根据表名拼接修改语句
DELETE_FROM(String table) 根据表名拼接删除语句

增删改查注解:

  • @SelectProvider:生成查询用的 SQL 语句
  • @InsertProvider:生成新增用的 SQL 语句
  • @UpdateProvider:生成修改用的 SQL 语句注解
  • @DeleteProvider:生成删除用的 SQL 语句注解。
    • type 属性:生成 SQL 语句功能类对象
    • method 属性:指定调用方法

基本操作

  • MyBatisConfig.xml 配置

    1
    2
    3
    4
     <!-- mappers引入映射配置文件 -->
    <mappers>
    <package name="mapper"/>
    </mappers>
  • Mapper 类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    public interface StudentMapper {
    //查询全部
    @SelectProvider(type = ReturnSql.class, method = "getSelectAll")
    public abstract List<Student> selectAll();

    //新增数据
    @InsertProvider(type = ReturnSql.class, method = "getInsert")
    public abstract Integer insert(Student student);

    //修改操作
    @UpdateProvider(type = ReturnSql.class, method = "getUpdate")
    public abstract Integer update(Student student);

    //删除操作
    @DeleteProvider(type = ReturnSql.class, method = "getDelete")
    public abstract Integer delete(Integer id);

    }
  • ReturnSQL 类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    public class ReturnSql {
    //定义方法,返回查询的sql语句
    public String getSelectAll() {
    return new SQL() {
    {
    SELECT("*");
    FROM("student");
    }
    }.toString();
    }

    //定义方法,返回新增的sql语句
    public String getInsert(Student stu) {
    return new SQL() {
    {
    INSERT_INTO("student");
    INTO_VALUES("#{id},#{name},#{age}");
    }
    }.toString();
    }

    //定义方法,返回修改的sql语句
    public String getUpdate(Student stu) {
    return new SQL() {
    {
    UPDATE("student");
    SET("name=#{name}","age=#{age}");
    WHERE("id=#{id}");
    }
    }.toString();
    }

    //定义方法,返回删除的sql语句
    public String getDelete(Integer id) {
    return new SQL() {
    {
    DELETE_FROM("student");
    WHERE("id=#{id}");
    }
    }.toString();
    }
    }
  • 功能实现类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    public class SqlTest {	
    @Test //查询全部
    public void selectAll() throws Exception{
    //1.加载核心配置文件
    InputStream is = Resources.getResourceAsStream("MyBatisConfig.xml");

    //2.获取SqlSession工厂对象
    SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(is);

    //3.通过工厂对象获取SqlSession对象
    SqlSession sqlSession = sqlSessionFactory.openSession(true);

    //4.获取StudentMapper接口的实现类对象
    StudentMapper mapper = sqlSession.getMapper(StudentMapper.class);

    //5.调用实现类对象中的方法,接收结果
    List<Student> list = mapper.selectAll();

    //6.处理结果
    for (Student student : list) {
    System.out.println(student);
    }

    //7.释放资源
    sqlSession.close();
    is.close();
    }

    @Test //新增
    public void insert() throws Exception{
    //1 2 3 4获取StudentMapper接口的实现类对象
    StudentMapper mapper = sqlSession.getMapper(StudentMapper.class);

    //5.调用实现类对象中的方法,接收结果 ->6 7
    Student stu = new Student(4,"赵六",26);
    Integer result = mapper.insert(stu);
    }

    @Test //修改
    public void update() throws Exception{
    //1 2 3 4 5调用实现类对象中的方法,接收结果 ->6 7
    Student stu = new Student(4,"赵六wq",36);
    Integer result = mapper.update(stu);
    }
    @Test //删除
    public void delete() throws Exception{
    //1 2 3 4 5 6 7
    Integer result = mapper.delete(4);
    }
    }

运行原理

运行机制

MyBatis 运行过程:

  1. 加载 MyBatis 全局配置文件,通过 XPath 方式解析 XML 配置文件,首先解析核心配置文件, 标签中配置属性项有 defaultExecutorType,用来配置指定 Executor 类型,将配置文件的信息填充到 Configuration对象。最后解析映射器配置的映射文件,并构建 MappedStatement 对象填充至 Configuration,将解析后的映射器添加到 mapperRegistry 中,用于获取代理

  2. 创建一个 DefaultSqlSession 对象,根据参数创建指定类型的 Executor,二级缓存默认开启,把 Executor 包装成缓存执行器

  3. DefaulSqlSession 调用 getMapper(),通过 JDK 动态代理获取 Mapper 接口的代理对象 MapperProxy

  4. 执行 SQL 语句:

    • MapperProxy.invoke() 执行代理方法,通过 MapperMethod#execute 判断执行的是增删改查中的哪个方法
    • 查询方法调用 sqlSession.selectOne(),从 Configuration 中获取执行者对象 MappedStatement,然后 Executor 调用 executor.query 开始执行查询方法
    • 首先通过 CachingExecutor 去二级缓存查询,查询不到去一级缓存查询,最后去数据库查询并放入一级缓存
    • Configuration 对象根据
    • 最后获取 JDBC 原生的 Connection 数据库连接对象,创建 Statement 执行者对象,然后通过 ParameterHandler 设置预编译参数,底层是 TypeHandler#setParameter 方法,然后通过 StatementHandler 回调执行者对象执行增删改查,最后调用 ResultsetHandler 处理查询结果

四大对象

  • StatementHandler:执行 SQL 语句的对象
  • ParameterHandler:设置预编译参数用的
  • ResultHandler:处理结果集
  • Executor:执行器,真正进行 Java 与数据库交互的对象

参考视频:https://www.bilibili.com/video/BV1mW411M737?p=71


获取工厂

SqlSessionFactoryBuilder.build(InputStream, String, Properties):构建工厂

XMLConfigBuilder.parse():解析核心配置文件每个标签的信息(XPath

  • parseConfiguration(parser.evalNode("/configuration")):读取节点内数据, 是 MyBatis 配置文件中的顶层标签

    settings = settingsAsProperties(root.evalNode("settings")):读取核心配置文件中的 标签

    settingsElement(settings):设置框架相关的属性

    • configuration.setCacheEnabled()设置缓存属性,默认是 true
    • configuration.setDefaultExecutorType()设置 Executor 类型到 configuration,默认是 SIMPLE

    mapperElement(root.evalNode("mappers")):解析 mappers 信息,分为 package 和 单个注册两种

    • if...else...:根据映射方法选择合适的读取方式

    • XMLMapperBuilder.parse():解析 mapper 的标签的信息

      • configurationElement(parser.evalNode("/mapper")):解析 mapper 文件,顶层节点

        • buildStatementFromContext(context.evalNodes("select...")):解析每个操作标签

          XMLStatementBuilder.parseStatementNode():解析操作标签的所有的属性

          builderAssistant.addMappedStatement(...)封装成 MappedStatement 对象加入 Configuration 对象,代表一个增删改查的标签

    • Class<?> mapperInterface = Resources.classForName(mapperClass):加载 Mapper 接口

    • Configuration.addMappers():将核心配置文件配置的映射器添加到 mapperRegistry 中,用来获取代理对象

      • MapperAnnotationBuilder parser = new MapperAnnotationBuilder(config, type):创建注解解析器

      • parser.parse():解析 Mapper 接口

        • SqlSource sqlSource = getSqlSourceFromAnnotations():获取 SQL 的资源对象

        • builderAssistant.addMappedStatement(...):封装成 MappedStatement 对象加入 Configuration 对象

  • return configuration:返回配置完成的 configuration 对象

return new DefaultSqlSessionFactory(config):返回工厂对象,包含 Configuration 对象

总结:解析 XML 是对 Configuration 中的属性进行填充,那么可以在一个类中创建 Configuration 对象,自定义其中属性的值来达到配置的效果


获取会话

DefaultSqlSessionFactory.openSession():获取 Session 对象,并且创建 Executor 对象

DefaultSqlSessionFactory.openSessionFromDataSource(…):ExecutorType 为 Executor 的类型,TransactionIsolationLevel 为事务隔离级别,autoCommit 是否开启事务

  • transactionFactory.newTransaction(DataSource, IsolationLevel, boolean:事务对象

  • configuration.newExecutor(tx, execType)根据参数创建指定类型的 Executor

    • 批量操作笔记的部分有讲解到 的属性 defaultExecutorType,根据配置创建对象
    • 二级缓存默认开启,会包装 Executor 对象 new CachingExecutor(executor)

return new DefaultSqlSession(configuration, executor, autoCommit):返回 DefaultSqlSession 对象


获取代理

Configuration.getMapper(Class, SqlSession):获取代理的 mapper 对象

MapperRegistry.getMapper(Class, SqlSession):MapperRegistry 是 Configuration 属性,在获取工厂对象时初始化

  • (MapperProxyFactory<T>) knownMappers.get(type):获取接口信息封装为 MapperProxyFactory 对象
  • mapperProxyFactory.newInstance(sqlSession)创建代理对象
    • new MapperProxy<>(sqlSession, mapperInterface, methodCache):包装对象
      • methodCache 是并发安全的 ConcurrentHashMap 集合,存放要执行的方法
      • MapperProxy<T> implements InvocationHandler 说明 MapperProxy 默认是一个 InvocationHandler 对象
    • Proxy.newProxyInstance()JDK 动态代理创建 MapperProxy 对象


执行SQL

MapperProxy.invoke():执行 SQL 语句,Object 类的方法直接执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
try {
// 当前方法是否是属于 Object 类中的方法
if (Object.class.equals(method.getDeclaringClass())) {
return method.invoke(this, args);
// 当前方法是否是默认方法
} else if (isDefaultMethod(method)) {
return invokeDefaultMethod(proxy, method, args);
}
} catch (Throwable t) {
throw ExceptionUtil.unwrapThrowable(t);
}
// 包装成一个 MapperMethod 对象并初始化该对象
final MapperMethod mapperMethod = cachedMapperMethod(method);
// 【根据 switch-case 判断使用的什么类型的 SQL 进行逻辑处理】,此处分析查询语句的查询操作
return mapperMethod.execute(sqlSession, args);
}

sqlSession.selectOne(String, Object):查询数据

1
2
3
4
5
6
7
8
9
10
11
12
13
public Object execute(SqlSession sqlSession, Object[] args) {
//.....
// 解析传入的参数
Object param = method.convertArgsToSqlCommandParam(args);
result = sqlSession.selectOne(command.getName(), param);
}
// DefaultSqlSession.selectList(String, Object)
public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
// 获取执行者对象
MappedStatement ms = configuration.getMappedStatement(statement);
// 开始执行查询语句,参数通过 wrapCollection() 包装成集合类
return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
}

Executor#query():

  • CachingExecutor.query():先执行 CachingExecutor 去二级缓存获取数据

    1
    2
    3
    public class CachingExecutor implements Executor {
    private final Executor delegate; // 包装了 BaseExecutor,二级缓存不存在数据调用 BaseExecutor 查询
    }
    • MappedStatement.getBoundSql(parameterObject)把 parameterObject 封装成 BoundSql

      构造函数中有:this.parameterObject = parameterObject

    • CachingExecutor.createCacheKey():创建缓存对象

    • ms.getCache():获取二级缓存

    • tcm.getObject(cache, key):尝试从二级缓存中获取数据

  • BaseExecutor.query():二级缓存不存在该数据,调用该方法

    • localCache.getObject(key) :尝试从本地缓存(一级缓存)获取数据
  • BaseExecutor.queryFromDatabase():缓存获取数据失败,开始从数据库获取数据,并放入本地缓存

    • SimpleExecutor.doQuery():执行 query

      • configuration.newStatementHandler():创建 StatementHandler 对象

        • 根据
        • 判断 BoundSql 是否被创建,没有创建会重新封装参数信息到 BoundSql
        • StatementHandler 的构造方法中,创建了 ParameterHandler 和 ResultSetHandler 对象
        • interceptorChain.pluginAll(statementHandler):拦截器链
      • prepareStatement():通过 StatementHandler 创建 JDBC 原生的 Statement 对象

        • getConnection()获取 JDBC 的 Connection 对象
        • handler.prepare():初始化 Statement 对象
          • instantiateStatement(Connection connection):Connection 中的方法实例化对象
            • 获取普通执行者对象:Connection.createStatement()
            • 获取预编译执行者对象Connection.prepareStatement()
        • handler.parameterize():进行参数的设置
          • ParameterHandler.setParameters()通过 ParameterHandler 设置参数
            • typeHandler.setParameter():底层通过 TypeHandler 实现,回调 JDBC 的接口进行设置
      • StatementHandler.query()调用 JDBC 原生的 PreparedStatement 执行 SQL

        1
        2
        3
        4
        5
        6
        7
        public <E> List<E> query(Statement statement, ResultHandler resultHandler) {
        // 获取 SQL 语句
        String sql = boundSql.getSql();
        statement.execute(sql);
        // 通过 ResultSetHandler 对象封装结果集,映射成 JavaBean
        return resultSetHandler.handleResultSets(statement);
        }

        resultSetHandler.handleResultSets(statement):处理结果集

        • handleResultSet(rsw, resultMap, multipleResults, null):底层回调

          • handleRowValues():逐行处理数据,根据是否配置了 属性选择是否使用简单结果集映射

            • 首先判断数据是否被限制行数,然后进行结果集的映射

            • 最后将数据存入 ResultHandler 对象,底层就是 List 集合

              1
              2
              3
              4
              5
              6
              public class DefaultResultHandler implements ResultHandler<Object> {
              private final List<Object> list;
              public void handleResult(ResultContext<?> context) {
              list.add(context.getResultObject());
              }
              }
        • return collapseSingleResultList(multipleResults):可能存在多个结果集的情况

    • localCache.putObject(key, list)放入一级(本地)缓存

return list.get(0):返回结果集的第一个数据


插件使用

插件原理

实现原理:插件是按照插件配置顺序创建层层包装对象,执行目标方法的之后,按照逆向顺序执行(栈)

在四大对象创建时:

  • 每个创建出来的对象不是直接返回的,而是 interceptorChain.pluginAll(parameterHandler)
  • 获取到所有 Interceptor(插件需要实现的接口),调用 interceptor.plugin(target)返回 target 包装后的对象
  • 插件机制可以使用插件为目标对象创建一个代理对象,代理对象可以拦截到四大对象的每一个执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Intercepts(
{
@Signature(type=StatementHandler.class,method="parameterize",args=java.sql.Statement.class)
})
public class MyFirstPlugin implements Interceptor{

//intercept:拦截目标对象的目标方法的执行
@Override
public Object intercept(Invocation invocation) throws Throwable {
System.out.println("MyFirstPlugin...intercept:" + invocation.getMethod());
//动态的改变一下sql运行的参数:以前1号员工,实际从数据库查询11号员工
Object target = invocation.getTarget();
System.out.println("当前拦截到的对象:" + target);
//拿到:StatementHandler==>ParameterHandler===>parameterObject
//拿到target的元数据
MetaObject metaObject = SystemMetaObject.forObject(target);
Object value = metaObject.getValue("parameterHandler.parameterObject");
System.out.println("sql语句用的参数是:" + value);
//修改完sql语句要用的参数
metaObject.setValue("parameterHandler.parameterObject", 11);
//执行目标方法
Object proceed = invocation.proceed();
//返回执行后的返回值
return proceed;
}

// plugin:包装目标对象的,为目标对象创建一个代理对象
@Override
public Object plugin(Object target) {
//可以借助 Plugin 的 wrap 方法来使用当前 Interceptor 包装我们目标对象
System.out.println("MyFirstPlugin...plugin:mybatis将要包装的对象" + target);
Object wrap = Plugin.wrap(target, this);
//返回为当前target创建的动态代理
return wrap;
}

// setProperties:将插件注册时的property属性设置进来
@Override
public void setProperties(Properties properties) {
System.out.println("插件配置的信息:" + properties);
}
}

核心配置文件:

1
2
3
4
5
6
7
<!--plugins:注册插件  -->
<plugins>
<plugin interceptor="mybatis.dao.MyFirstPlugin">
<property name="username" value="root"/>
<property name="password" value="123456"/>
</plugin>
</plugins>

分页插件

  • 分页可以将很多条结果进行分页显示。如果当前在第一页,则没有上一页。如果当前在最后一页,则没有下一页,需要明确当前是第几页,这一页中显示多少条结果。
  • MyBatis 是不带分页功能的,如果想实现分页功能,需要手动编写 LIMIT 语句,不同的数据库实现分页的 SQL 语句也是不同,手写分页 成本较高。
  • PageHelper:第三方分页助手,将复杂的分页操作进行封装,从而让分页功能变得非常简单

分页操作

开发步骤:

  1. 导入 PageHelper 的 Maven 坐标

  2. 在 MyBatis 核心配置文件中配置 PageHelper 插件

    注意:分页助手的插件配置在通用 Mapper 之前

    1
    2
    3
    4
    5
    6
    7
    <plugins>
    <plugin interceptor="com.github.pagehelper.PageInterceptor">
    <!-- 指定方言 -->
    <property name="dialect" value="mysql"/>
    </plugin>
    </plugins>
    <mappers>.........</mappers>
  3. 与 MySQL 分页查询页数计算公式不同

    static <E> Page<E> startPage(int pageNum, int pageSize):pageNum第几页,pageSize页面大小

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Test
    public void selectAll() {
    //第一页:显示2条数据
    PageHelper.startPage(1,2);
    List<Student> students = sqlSession.selectList("StudentMapper.selectAll");
    for (Student student : students) {
    System.out.println(student);
    }
    }

参数获取

PageInfo构造方法:

  • PageInfo<Student> info = new PageInfo<>(list) : list 是 SQL 执行返回的结果集合,参考上一节

PageInfo相关API:

  1. startPage():设置分页参数
  2. PageInfo:分页相关参数功能类。
  3. getTotal():获取总条数
  4. getPages():获取总页数
  5. getPageNum():获取当前页
  6. getPageSize():获取每页显示条数
  7. getPrePage():获取上一页
  8. getNextPage():获取下一页
  9. isIsFirstPage():获取是否是第一页
  10. isIsLastPage():获取是否是最后一页

Spring

概述

框架

框架源自于建筑学,隶属土木工程,后发展到软件工程领域

软件工程框架:经过验证的,具有一定功能的,半成品软件

  • 经过验证

  • 具有一定功能

  • 半成品

框架作用:

  • 提高开发效率

  • 增强可重用性

  • 提供编写规范

  • 节约维护成本

  • 解耦底层实现原理

参考视频:https://space.bilibili.com/37974444


Spring

Spring 是分层的 JavaSE/EE 应用 full-stack 轻量级开源框架

Spring 优点:

  • 方便解耦,简化开发
  • 方便集成各种框架
  • 方便程序测试
  • AOP 编程难过的支持
  • 声明式事务的支持
  • 降低 JavaEE API 的使用难度

体系结构:


IoC

基本概述

  • IoC(Inversion Of Control)控制反转,Spring 反向控制应用程序所需要使用的外部资源
  • Spring 控制的资源全部放置在 Spring 容器中,该容器称为 IoC 容器(存放实例对象)
  • 官方网站:https://spring.io/ → Projects → spring-framework → LEARN → Reference Doc

  • 耦合(Coupling):代码编写过程中所使用技术的结合紧密度,用于衡量软件中各个模块之间的互联程度
  • 内聚(Cohesion):代码编写过程中单个模块内部各组成部分间的联系,用于衡量软件中各个功能模块内部的功能联系
  • 代码编写的目标:高内聚,低耦合。同一个模块内的各个元素之间要高度紧密,各个模块之间的相互依存度不紧密

入门项目

模拟三层架构中表现层调用业务层功能

  • 表现层:UserApp 模拟 UserServlet(使用 main 方法模拟)

  • 业务层:UserService

步骤:

  1. 导入 spring 坐标(5.1.9.release)

    1
    2
    3
    4
    5
    <dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context</artifactId>
    <version>5.1.9.RELEASE</version>
    </dependency>
  2. 编写业务层与表现层(模拟)接口与实现类 service.UserService,service.impl.UserServiceImpl

    1
    2
    3
    4
    public interface UserService {
    //业务方法
    void save();
    }
    1
    2
    3
    4
    5
    public class UserServiceImpl implements UserService {
    public void save() {
    System.out.println("user service running...");
    }
    }
  3. 建立 Spring 配置文件:resources.applicationContext.xml (名字一般使用该格式)

  4. 配置所需资源(Service)为 Spring 控制的资源

    1
    2
    3
    4
    5
    6
    7
    8
    <?xml version="1.0" encoding="UTF-8"?>
    <beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.springframework.org/schema/beans
    https://www.springframework.org/schema/beans/spring-beans.xsd">
    <!-- 1.创建spring控制的资源-->
    <bean id="userService" class="service.impl.UserServiceImpl"/>
    </beans>
  5. 表现层(App)通过 Spring 获取资源(Service 实例)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public class UserApp {
    public static void main(String[] args) {
    //2.加载配置文件
    ApplicationContext ctx = new ClassPathXmlApplicationContext("applicationContext.xml");
    //3.获取资源
    UserService userService = (UserService) ctx.getBean("userService");
    userService.save();//user service running...
    }
    }


XML开发

bean

基本属性

标签: 标签, 的子标签

作用:定义 Spring 中的资源,受此标签定义的资源将受到 Spring 控制

格式:

1
2
3
<beans>
<bean />
</beans>

基本属性

  • id:bean 的名称,通过 id 值获取 bean (首字母小写)
  • class:bean 的类型,使用完全限定类名
  • name:bean 的名称,可以通过 name 值获取 bean,用于多人配合时给 bean 起别名
1
<bean id="beanId" name="beanName1,beanName2" class="ClassName"></bean>
1
ctx.getBean("beanId") == ctx.getBean("beanName1") == ctx.getBean("beanName2")

作用范围

作用:定义 bean 的作用范围

格式:

1
<bean scope="singleton"></bean>

取值:

  • singleton:设定创建出的对象保存在 Spring 容器中,是一个单例的对象
  • prototype:设定创建出的对象保存在 Spring 容器中,是一个非单例(原型)的对象
  • request、session、application、 websocket :设定创建出的对象放置在 web 容器对应的位置

Spring 容器中 Bean 的线程安全问题:

  • 原型 Bean,每次创建一个新对象,线程之间并不存在 Bean 共享,所以不会有线程安全的问题

  • 单例 Bean,所有线程共享一个单例实例 Bean,因此是存在资源的竞争,如果单例 Bean是一个无状态 Bean,也就是线程中的操作不会对 Bean 的成员执行查询以外的操作,那么这个单例 Bean 是线程安全的

    解决方法:开发人员来进行线程安全的保证,最简单的办法就是把 Bean 的作用域 singleton 改为 protopyte


生命周期

作用:定义 bean 对象在初始化或销毁时完成的工作

格式:

1
<bean init-method="init" destroy-method="destroy></bean>

取值:bean 对应的类中对应的具体方法名

实现接口的方式实现初始化,无需配置文件配置 init-method:

  • 实现 InitializingBean,定义初始化逻辑
  • 实现 DisposableBean,定义销毁逻辑

注意事项:

  • 当 scope=“singleton” 时,Spring 容器中有且仅有一个对象,init 方法在创建容器时仅执行一次
  • 当 scope=“prototype” 时,Spring 容器要创建同一类型的多个对象,init 方法在每个对象创建时均执行一次
  • 当 scope=“singleton” 时,关闭容器(.close())会导致 bean 实例的销毁,调用 destroy 方法一次
  • 当 scope=“prototype” 时,对象的销毁由垃圾回收机制 GC 控制,destroy 方法将不会被执行

bean 配置:

1
2
<!--init-method和destroy-method用于控制bean的生命周期-->
<bean id="userService3" scope="prototype" init-method="init" destroy-method="destroy" class="service.impl.UserServiceImpl"/>

业务层实现类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class UserServiceImpl implements UserService{
public UserServiceImpl(){
System.out.println(" constructor is running...");
}

public void init(){
System.out.println("init....");
}

public void destroy(){
System.out.println("destroy....");
}

public void save() {
System.out.println("user service running...");
}
}

测试类:

1
UserService userService = (UserService)ctx.getBean("userService3");

创建方式
  • 静态工厂

    作用:定义 bean 对象创建方式,使用静态工厂的形式创建 bean,兼容早期遗留系统的升级工作

    格式:

    1
    <bean class="FactoryClassName" factory-method="factoryMethodName"></bean>

    取值:工厂 bean 中用于获取对象的静态方法名

    注意事项:class 属性必须配置成静态工厂的类名

    bean 配置:

    1
    2
    <!--静态工厂创建 bean-->
    <bean id="userService4" class="service.UserServiceFactory" factory-method="getService"/>

    工厂类:

    1
    2
    3
    4
    5
    6
    public class UserServiceFactory {
    public static UserService getService(){
    System.out.println("factory create object...");
    return new UserServiceImpl();
    }
    }

    测试类:

    1
    UserService userService = (UserService)ctx.getBean("userService4");
  • 实例工厂

    作用:定义 bean 对象创建方式,使用实例工厂的形式创建 bean,兼容早期遗留系统的升级工作

    格式:

    1
    <bean factory-bean="factoryBeanId" factory-method="factoryMethodName"></bean>

    注意事项:

    • 使用实例工厂创建 bean 首先需要将实例工厂配置 bean,交由 Spring 进行管理

    • factory-bean 是实例工厂的 Id

    bean 配置:

    1
    2
    3
    <!--实例工厂创建 bean,依赖工厂对象对应的 bean-->
    <bean id="factoryBean" class="service.UserServiceFactory2"/>
    <bean id="userService5" factory-bean="factoryBean" factory-method="getService"/>

    工厂类:

    1
    2
    3
    4
    5
    6
    public class UserServiceFactory2 {
    public UserService getService(){
    System.out.println(" instance factory create object...");
    return new UserServiceImpl();
    }
    }

获取Bean

ApplicationContext 子类相关API:

方法 说明
String[] getBeanDefinitionNames() 获取 Spring 容器中定义的所有 JavaBean 的名称
BeanDefinition getBeanDefinition(String beanName) 返回给定 bean 名称的 BeanDefinition
String[] getBeanNamesForType(Class<?> type) 获取 Spring 容器中指定类型的所有 JavaBean 的名称
Environment getEnvironment() 获取与此组件关联的环境

DI

依赖注入
  • IoC(Inversion Of Control)控制翻转,Spring 反向控制应用程序所需要使用的外部资源

  • DI(Dependency Injection)依赖注入,应用程序运行依赖的资源由 Spring 为其提供,资源进入应用程序的方式称为注入,简单说就是利用反射机制为类的属性赋值的操作

IoC 和 DI 的关系:IoC 与 DI 是同一件事站在不同角度看待问题


set 注入

标签: 标签, 的子标签

作用:使用 set 方法的形式为 bean 提供资源

格式:

1
2
3
4
5
<bean>
<property />
<property />
.....
</bean>

基本属性:

  • name:对应 bean 中的属性名,要注入的变量名,要求该属性必须提供可访问的 set 方法(严格规范此名称是 set 方法对应名称,首字母必须小写)
  • value:设定非引用类型属性对应的值,不能与 ref 同时使用
  • ref:设定引用类型属性对应 bean 的 id ,不能与 value 同时使用
1
<property name="propertyName" value="propertyValue" ref="beanId"/>

代码实现:

  • DAO 层:要注入的资源

    1
    2
    3
    public interface UserDao {
    public void save();
    }
    1
    2
    3
    4
    5
    public class UserDaoImpl implements UserDao{
    public void save(){
    System.out.println("user dao running...");
    }
    }
  • Service 业务层

    1
    2
    3
    public interface UserService {
    public void save();
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    public class UserServiceImpl implements UserService {
    private UserDao userDao;
    private int num;

    //1.对需要进行注入的变量添加set方法
    public void setUserDao(UserDao userDao) {
    this.userDao = userDao;
    }

    public void setNum(int num) {
    this.num = num;
    }

    public void save() {
    System.out.println("user service running..." + num);
    userDao.save();
    bookDao.save();
    }
    }
  • 配置 applicationContext.xml

    1
    2
    3
    4
    5
    6
    7
    8
    <!--2.将要注入的资源声明为bean-->
    <bean id="userDao" class="dao.impl.UserDaoImpl"/>

    <bean id="userService" class="service.impl.UserServiceImpl">
    <!--3.将要注入的引用类型的变量通过property属性进行注入,-->
    <property name="userDao" ref="userDao"/>
    <property name="num" value="666"/>
    </bean>
  • 测试类

    1
    2
    3
    4
    5
    6
    7
    public class UserApp {
    public static void main(String[] args) {
    ApplicationContext ctx = new ClassPathXmlApplicationContext("applicationContext.xml");
    UserService userService = (UserService) ctx.getBean("userService");
    userService.save();
    }
    }

构造注入

标签: 标签, 的子标签

作用:使用构造方法的形式为 bean 提供资源,兼容早期遗留系统的升级工作

格式:

1
2
3
4
<bean>
<constructor-arg />
.....<!--一个bean可以有多个constructor-arg标签-->
</bean>

属性:

  • name:对应bean中的构造方法所携带的参数名
  • value:设定非引用类型构造方法参数对应的值,不能与 ref 同时使用
  • ref:设定引用类型构造方法参数对应 bean 的 id ,不能与 value 同时使用
  • type:设定构造方法参数的类型,用于按类型匹配参数或进行类型校验
  • index:设定构造方法参数的位置,用于按位置匹配参数,参数 index 值从 0 开始计数
1
2
<constructor-arg name="argsName" value="argsValue" />
<constructor-arg index="arg-index" type="arg-type" ref="beanId"/>

代码实现:

  • DAO 层:要注入的资源

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public class UserDaoImpl implements UserDao{
    private String username;
    private String pwd;
    private String driver;

    public UserDaoImpl(String driver,String username, String pwd) {
    this.driver = driver;
    this.username = username;
    this.pwd = pwd;
    }
    public void save(){
    System.out.println("user dao running..."+username+" "+pwd+" "+driver);
    }
    }
  • Service 业务层:参考 set 注入

  • 配置 applicationContext.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <bean id="userDao" class="dao.impl.UserDaoImpl">
    <!--使用构造方法进行注入,需要保障注入的属性与bean中定义的属性一致-->
    <!--一致指顺序一致或类型一致或使用index解决该问题-->
    <constructor-arg index="2" value="123"/>
    <constructor-arg index="1" value="root"/>
    <constructor-arg index="0" value="com.mysql.jdbc.Driver"/>
    </bean>

    <bean id="userService" class="service.impl.UserServiceImpl">
    <property name="userDao" ref="userDao"/>
    <property name="num" value="666"/>
    </bean>

    方式二:使用 UserServiceImpl 的构造方法注入

    1
    2
    3
    4
    <bean id="userService" class="service.impl.UserServiceImpl">
    <constructor-arg name="userDao" ref="userDao"/>
    <constructor-arg name="num" value="666666"/>
    </bean>
  • 测试类:参考 set 注入


集合注入

标签: 标签的子标签

作用:注入集合数据类型属性

格式:

1
2
3
<property>
<list></list>
</property>

代码实现:

  • DAO 层:要注入的资源

    1
    2
    3
    public interface BookDao {
    public void save();
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    public class BookDaoImpl implements BookDao {
    private ArrayList al;
    private Properties properties;
    private int[] arr;
    private HashSet hs;
    private HashMap hm ;

    public void setAl(ArrayList al) {
    this.al = al;
    }

    public void setProperties(Properties properties) {
    this.properties = properties;
    }

    public void setArr(int[] arr) {
    this.arr = arr;
    }

    public void setHs(HashSet hs) {
    this.hs = hs;
    }

    public void setHm(HashMap hm) {
    this.hm = hm;
    }

    public void save() {
    System.out.println("book dao running...");
    System.out.println("ArrayList:" + al);
    System.out.println("Properties:" + properties);
    for (int i = 0; i < arr.length; i++) {
    System.out.println(arr[i]);
    }
    System.out.println("HashSet:" + hs);
    System.out.println("HashMap:" + hm);
    }
    }
  • Service 业务层

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public class UserServiceImpl implements UserService {
    private BookDao bookDao;

    public UserServiceImpl() {}

    public void setBookDao(BookDao bookDao) {
    this.bookDao = bookDao;
    }

    public void save() {
    System.out.println("user service running..." + num);
    bookDao.save();
    }
    }
  • 配置 applicationContext.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    <bean id="userService" class="service.impl.UserServiceImpl">
    <property name="bookDao" ref="bookDao"/>
    </bean>

    <bean id="bookDao" class="dao.impl.BookDaoImpl">
    <property name="al">
    <list>
    <value>seazean</value>
    <value>66666</value>
    </list>
    </property>
    <property name="properties">
    <props>
    <prop key="name">seazean666</prop>
    <prop key="value">666666</prop>
    </props>
    </property>
    <property name="arr">
    <array>
    <value>123456</value>
    <value>66666</value>
    </array>
    </property>
    <property name="hs">
    <set>
    <value>seazean</value>
    <value>66666</value>
    </set>
    </property>
    <property name="hm">
    <map>
    <entry key="name" value="seazean66666"/>
    <entry key="value" value="6666666666"/>
    </map>
    </property>
    </bean>

P

标签:<p:propertyName>,<p:propertyName-ref>

作用:为 bean 注入属性值

格式:

1
<bean p:propertyName="propertyValue" p:propertyName-ref="beanId"/>

开启 p 命令空间:开启 Spring 对 p 命令空间的的支持,在 beans 标签中添加对应空间支持

1
2
3
4
5
6
7
<beans xmlns="http://www.springframework.org/schema/beans"   		
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:p="http://www.springframework.org/schema/p"
xsi:schemaLocation="
http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd">
</beans>

实例:

1
2
3
4
5
6
7
<bean 
id="userService"
class="service.impl.UserServiceImpl"
p:userDao-ref="userDao"
p:bookDao-ref="bookDao"
p:num="10"
/>

SpEL

Spring 提供了对 EL 表达式的支持,统一属性注入格式

作用:为 bean 注入属性值

格式:

1
<property value="EL">

注意:所有属性值不区分是否引用类型,统一使用value赋值

所有格式统一使用 value=“#{}”

  • 常量 #{10} #{3.14} #{2e5} #{‘it’}

  • 引用 bean #{beanId}

  • 引用 bean 属性 #{beanId.propertyName}

  • 引用 bean 方法 beanId.methodName().method2()

  • 引用静态方法 T(java.lang.Math).PI

  • 运算符支持 #{3 lt 4 == 4 ge 3}

  • 正则表达式支持 #{user.name matches‘[a-z]{6,}’}

  • 集合支持 #{likes[3]}

实例:

1
2
3
4
5
<bean id="userService" class="service.impl.UserServiceImpl">
<property name="userDao" value="#{userDao}"/>
<property name="bookDao" value="#{bookDao}"/>
<property name="num" value="#{666666666}"/>
</bean>

prop

Spring 提供了读取外部 properties 文件的机制,使用读取到的数据为 bean 的属性赋值

操作步骤:

  1. 准备外部 properties 文件

  2. 开启 context 命名空间支持

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <?xml version="1.0" encoding="UTF-8"?>
    <beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:context="http://www.springframework.org/schema/context"
    xsi:schemaLocation="
    http://www.springframework.org/schema/beans
    https://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/context
    https://www.springframework.org/schema/context/spring-context.xsd
    ">
  3. 加载指定的 properties 文件

    1
    <context:property-placeholder location="classpath:data.properties" />
  4. 使用加载的数据

    1
    <property name="propertyName" value="${propertiesName}"/>
  • 注意:如果需要加载所有的 properties 文件,可以使用 *.properties 表示加载所有的 properties 文件

  • 注意:读取数据使用 ${propertiesName} 格式进行,其中 propertiesName 指 properties 文件中的属性名

代码实现:

  • data.properties

    1
    2
    username=root
    pwd=123456
  • DAO 层:注入的资源

    1
    2
    3
    public interface UserDao {
    public void save();
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public class UserDaoImpl implements UserDao{
    private String userName;
    private String password;

    public void setUserName(String userName) {
    this.userName = userName;
    }
    public void setPassword(String password) {
    this.password = password;
    }

    public void save(){
    System.out.println("user dao running..."+userName+" "+password);
    }
    }
  • Service 业务层

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public class UserServiceImpl implements UserService {
    private UserDao userDao;
    public void setUserDao(UserDao userDao) {
    this.userDao = userDao;
    }
    public void save() {
    System.out.println("user service running...");
    userDao.save();
    }
    }
  • applicationContext.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <context:property-placeholder location="classpath:*.properties"/>

    <bean id="userDao" class="dao.impl.UserDaoImpl">
    <property name="userName" value="${username}"/>
    <property name="password" value="${pwd}"/>
    </bean>

    <bean id="userService" class="service.impl.UserServiceImpl">
    <property name="userDao" ref="userDao"/>
    </bean>
  • 测试类

    1
    2
    3
    4
    5
    6
    7
    public class UserApp {
    public static void main(String[] args) {
    ApplicationContext ctx = new ClassPathXmlApplicationContext("applicationContext.xml");
    UserService userService = (UserService) ctx.getBean("userService");
    userService.save();
    }
    }

import

标签:标签的子标签

作用:在当前配置文件中导入其他配置文件中的项

格式:

1
2
3
<beans>
<import />
</beans>

属性:

  • resource:加载的配置文件名
1
<import resource=“config2.xml"/>

Spring 容器加载多个配置文件:

  • applicationContext-book.xml

    1
    2
    3
    <bean id="bookDao" class="dao.impl.BookDaoImpl">
    <property name="num" value="1"/>
    </bean>
  • applicationContext-user.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <bean id="userDao" class="dao.impl.UserDaoImpl">
    <property name="userName" value="${username}"/>
    <property name="password" value="${pwd}"/>
    </bean>

    <bean id="userService" class="service.impl.UserServiceImpl">
    <property name="userDao" ref="userDao"/>
    <property name="bookDao" ref="bookDao"/>
    </bean>
  • applicationContext.xml

    1
    2
    3
    4
    5
    6
    <import resource="applicationContext-user.xml"/>
    <import resource="applicationContext-book.xml"/>

    <bean id="bookDao" class="com.seazean.dao.impl.BookDaoImpl">
    <property name="num" value="2"/>
    </bean>
  • 测试类

    1
    2
    new ClassPathXmlApplicationContext("applicationContext-user.xml","applicationContext-book.xml");
    new ClassPathXmlApplicationContext("applicationContext.xml");

Spring 容器中的 bean 定义冲突问题

  • 同 id 的 bean,后定义的覆盖先定义的

  • 导入配置文件可以理解为将导入的配置文件复制粘贴到对应位置,程序执行选择最下面的配置使用

  • 导入配置文件的顺序与位置不同可能会导致最终程序运行结果不同


三方资源

Druid

第三方资源配置

  • pom.xml

    1
    2
    3
    4
    5
    <dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid</artifactId>
    <version>1.1.16</version>
    </dependency>
  • applicationContext.xml

    1
    2
    3
    4
    5
    6
    7
    <!--加载druid资源-->
    <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource">
    <property name="driverClassName" value="com.mysql.jdbc.Driver"/>
    <property name="url" value="jdbc:mysql://192.168.2.185:3306/spring_db"/>
    <property name="username" value="root"/>
    <property name="password" value="123456"/>
    </bean>
  • App.java

    1
    2
    3
    ApplicationContext ctx = new ClassPathXmlApplicationContext("applicationContext.xml");
    DruidDataSource datasource = (DruidDataSource) ctx.getBean("datasource");
    System.out.println(datasource);

Mybatis

Mybatis 核心配置文件消失

  • 环境 environment 转换成数据源对象

  • 映射 Mapper 扫描工作交由 Spring 处理

  • 类型别名交由 Spring 处理

DAO 接口不需要创建实现类,MyBatis-Spring 提供了一个动态代理的实现 MapperFactoryBean,这个类可以让直接注入数据映射器接口到 service 层 bean 中,底层将会动态代理创建类

整合原理:利用 Spring 框架的 SPI 机制,在 META-INF 目录的 spring.handlers 中给 Spring 容器中导入 NamespaceHandler 类

  • NamespaceHandler 的 init 方法注册 bean 信息的解析器 MapperScannerBeanDefinitionParser

  • 解析器在 Spring 容器创建过程中去解析 mapperScanner 标签,解析出的属性填充到 MapperScannerConfigurer 中

  • MapperScannerConfigurer 实现了 BeanDefinitionRegistryPostProcessor 接口,重写 postProcessBeanDefinitionRegistry() 方法,可以扫描到 MyBatis 的 Mapper


注解开发

注解驱动

XML

启动注解扫描,加载类中配置的注解项:

1
<context:component-scan base-package="packageName1,packageName2"/>

说明:

  • 在进行包扫描时,会对配置的包及其子包中所有文件进行扫描,多个包采用,隔开
  • 扫描过程是以文件夹递归迭代的形式进行的
  • 扫描过程仅读取合法的 Java 文件
  • 扫描时仅读取 Spring 可识别的注解
  • 扫描结束后会将可识别的有效注解转化为 Spring 对应的资源加入 IoC 容器
  • 从加载效率上来说注解优于 XML 配置文件

注解:启动时使用注解的形式替代 xml 配置,将 Spring 配置文件从工程中消除,简化书写

缺点:为了达成注解驱动的目的,可能会将原先很简单的书写,变的更加复杂。XML 中配置第三方开发的资源是很方便的,但使用注解驱动无法在第三方开发的资源中进行编辑,因此会增大开发工作量


纯注解

注解配置类

名称:@Configuration、@ComponentScan

类型:类注解

作用:设置当前类为 Spring 核心配置加载类

格式:

1
2
3
4
@Configuration
@ComponentScan({"scanPackageName1","scanPackageName2"})
public class SpringConfigClassName{
}

说明:

  • 核心配合类用于替换 Spring 核心配置文件,此类可以设置空的,不设置变量与属性
  • bean 扫描工作使用注解 @ComponentScan 替代,多个包用 {} 和 , 隔开

加载纯注解格式上下文对象,需要使用 AnnotationConfigApplicationContext

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Configuration
public class SpringConfig {
@Bean
public Person person() {
return new Person1("lisi", 20);
}
}

public class MainTest {
public static void main(String[] args) {
ApplicationContext applicationContext = new
AnnotationConfigApplicationContext(SpringConfig.class);
//方式一:名称对应类名
Person bean = applicationContext.getBean(Person.class);
System.out.println(bean);

//方式二:名称对应方法名
Person bean1 = (Person) applicationContext.getBean("person1");

//方法三:指定名称@Bean("person2")
}
}

扫描器

组件扫描过滤器

开发过程中,需要根据需求加载必要的 bean,排除指定 bean

名称:@ComponentScan

类型:类注解

作用:设置 Spring 配置加载类扫描规则

格式:

1
2
3
4
5
6
7
8
@ComponentScan(
value = {"dao","service"}, //设置基础扫描路径
excludeFilters = //设置过滤规则,当前为排除过滤
@ComponentScan.Filter( //设置过滤器
type= FilterType.ANNOTATION, //设置过滤方式为按照注解进行过滤
classes = Service.class) //设置具体的过滤项,过滤所有@Service修饰的bean
)
)

属性:

  • includeFilters:设置包含性过滤器
  • excludeFilters:设置排除性过滤器
  • type:设置过滤器类型

基本注解

设置 bean

名称:@Component、@Controller、@Service、@Repository

类型:类注解,写在类定义上方

作用:设置该类为 Spring 管理的 bean

格式:

1
2
@Component
public class ClassName{}

说明:@Controller、@Service 、@Repository 是 @Component 的衍生注解,功能同 @Component

属性:

  • value(默认):定义 bean 的访问 id

作用范围

名称:@Scope

类型:类注解,写在类定义上方

作用:设置该类作为 bean 对应的 scope 属性

格式:

1
2
@Scope
public class ClassName{}

相关属性

  • value(默认):定义 bean 的作用域,默认为 singleton,非单例取值 prototype

生命周期

名称:@PostConstruct、@PreDestroy

类型:方法注解,写在方法定义上方

作用:设置该类作为 bean 对应的生命周期方法

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//定义bean,后面添加bean的id
@Component("userService")
//定义bean的作用域
@Scope("singleton")
public class UserServiceImpl implements UserService {
//初始化
@PostConstruct
public void init(){
System.out.println("user service init...");
}
//销毁
@PreDestroy
public void destroy(){
System.out.println("user service destroy...");
}
}

一个对象的执行顺序:Constructor >> @Autowired(注入属性) >> @PostConstruct(初始化逻辑)


加载资源

名称:@Bean

类型:方法注解

作用:设置该方法的返回值作为 Spring 管理的 bean

格式:

1
2
@Bean("dataSource")
public DruidDataSource createDataSource() { return ……; }

说明:

  • 因为第三方 bean 无法在其源码上进行修改,使用 @Bean 解决第三方 bean 的引入问题

  • 该注解用于替代 XML 配置中的静态工厂与实例工厂创建 bean,不区分方法是否为静态或非静态

  • @Bean 所在的类必须被 Spring 扫描加载,否则该注解无法生效

相关属性

  • value(默认):定义 bean 的访问 id
  • initMethod:声明初始化方法
  • destroyMethod:声明销毁方法

属性注入

基本类型

名称:@Value

类型:属性注解、方法注解

作用:设置对应属性的值或对方法进行传参

格式:

1
2
3
//@Value("${jdbc.username}")
@Value("root")
private String username;

说明:

  • value 值仅支持非引用类型数据,赋值时对方法的所有参数全部赋值

  • value 值支持读取 properties 文件中的属性值,通过类属性将 properties 中数据传入类中

  • value 值支持 SpEL

  • @value 注解如果添加在属性上方,可以省略 set 方法(set 方法的目的是为属性赋值)

相关属性:

  • value(默认):定义对应的属性值或参数值

自动装配
属性注入

名称:@Autowired、@Qualifier

类型:属性注解、方法注解

作用:设置对应属性的对象、对方法进行引用类型传参

格式:

1
2
3
@Autowired(required = false)
@Qualifier("userDao")
private UserDao userDao;

说明:

  • @Autowired 默认按类型装配,指定 @Qualifier 后可以指定自动装配的 bean 的 id

相关属性:

  • required:为 true (默认)表示注入 bean 时该 bean 必须存在,不然就会注入失败抛出异常;为 false 表示注入时该 bean 存在就注入,不存在就忽略跳过

注意:在使用 @Autowired 时,首先在容器中查询对应类型的 bean,如果查询结果刚好为一个,就将该 bean 装配给 @Autowired 指定的数据,如果查询的结果不止一个,那么 @Autowired 会根据名称来查找,如果查询的结果为空,那么会抛出异常

解决方法:使用 required = false


优先注入

名称:@Primary

类型:类注解

作用:设置类对应的 bean 按类型装配时优先装配

范例:

1
2
@Primary
public class ClassName{}

说明:

  • @Autowired 默认按类型装配,当出现相同类型的 bean,使用 @Primary 提高按类型自动装配的优先级,多个 @Primary 会导致优先级设置无效

注解对比

名称:@Inject、@Named、@Resource

  • @Inject 与 @Named 是 JSR330 规范中的注解,功能与 @Autowired 和 @Qualifier 完全相同,适用于不同架构场景
  • @Resource 是 JSR250 规范中的注解,可以简化书写格式

@Resource 相关属性

  • name:设置注入的 bean 的 id

  • type:设置注入的 bean 的类型,接收的参数为 Class 类型

@Autowired 和 @Resource之间的区别:

  • @Autowired 默认是按照类型装配注入,默认情况下它要求依赖对象必须存在(可以设置 required 属性为 false)

  • @Resource 默认按照名称装配注入,只有当找不到与名称匹配的 bean 才会按照类型来装配注入


静态注入

Spring 容器管理的都是实例对象,**@Autowired 依赖注入的都是容器内的对象实例**,在 Java 中 static 修饰的静态属性(变量和方法)是属于类的,而非属于实例对象

当类加载器加载静态变量时,Spring 上下文尚未加载,所以类加载器不会在 Bean 中正确注入静态类

1
2
3
4
5
6
7
8
9
10
11
@Component
public class TestClass {
@Autowired
private static Component component;

// 调用静态组件的方法
public static void testMethod() {
component.callTestMethod();
}
}
// 编译正常,但运行时报java.lang.NullPointerException,所以在调用testMethod()方法时,component变量还没被初始化

解决方法:

  • @Autowired 注解到类的构造函数上,Spring 扫描到 Component 的 Bean,然后赋给静态变量 component

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    @Component
    public class TestClass {
    private static Component component;

    @Autowired
    public TestClass(Component component) {
    TestClass.component = component;
    }

    public static void testMethod() {
    component.callTestMethod();
    }
    }
  • @Autowired 注解到静态属性的 setter 方法

  • 使用 @PostConstruct 注解一个方法,在方法内为 static 静态成员赋值

  • 使用 Spring 框架工具类获取 bean,定义成局部变量使用

    1
    2
    3
    4
    5
    6
    7
    public class TestClass {
    // 调用静态组件的方法
    public static void testMethod() {
    Component component = SpringApplicationContextUtil.getBean("component");
    component.callTestMethod();
    }
    }

参考文章:http://jessehzx.top/2018/03/18/spring-autowired-static-field/


文件读取

名称:@PropertySource

类型:类注解

作用:加载 properties 文件中的属性值

格式:

1
2
3
4
5
@PropertySource(value = "classpath:filename.properties")
public class ClassName {
@Value("${propertiesAttributeName}")
private String attributeName;
}

说明:

  • 不支持 * 通配符,加载后,所有 Spring 控制的 bean 中均可使用对应属性值,加载多个需要用 {} 和 , 隔开

相关属性

  • value(默认):设置加载的 properties 文件名

  • ignoreResourceNotFound:如果资源未找到,是否忽略,默认为 false


加载控制

依赖加载

@DependsOn

  • 名称:@DependsOn

  • 类型:类注解、方法注解

  • 作用:控制 bean 的加载顺序,使其在指定 bean 加载完毕后再加载

  • 格式:

    1
    2
    3
    @DependsOn("beanId")
    public class ClassName {
    }
  • 说明:

    • 配置在方法上,使 @DependsOn 指定的 bean 优先于 @Bean 配置的 bean 进行加载

    • 配置在类上,使 @DependsOn 指定的 bean 优先于当前类中所有 @Bean 配置的 bean 进行加载

    • 配置在类上,使 @DependsOn 指定的 bean 优先于 @Component 等配置的 bean 进行加载

  • 相关属性

    • value(默认):设置当前 bean 所依赖的 bean 的 id

@Order

  • 名称:@Order

  • 类型:配置类注解

  • 作用:控制配置类的加载顺序,值越小越先加载

  • 格式:

    1
    2
    3
    @Order(1)
    public class SpringConfigClassName {
    }

@Lazy

  • 名称:@Lazy

  • 类型:类注解、方法注解

  • 作用:控制 bean 的加载时机,使其延迟加载,获取的时候加载

  • 格式:

    1
    2
    3
    @Lazy
    public class ClassName {
    }

应用场景

@DependsOn

  • 微信订阅号,发布消息和订阅消息的 bean 的加载顺序控制(先开订阅,再发布)

  • 双 11 活动,零点前是结算策略 A,零点后是结算策略 B,策略 B 操作的数据为促销数据,策略 B 加载顺序与促销数据的加载顺序

@Lazy

  • 程序灾难出现后对应的应急预案处理是启动容器时加载时机

@Order

  • 多个种类的配置出现后,优先加载系统级的,然后加载业务级的,避免细粒度的加载控制

整合资源

导入

名称:@Import

类型:类注解

作用:导入第三方 bean 作为 Spring 控制的资源,这些类都会被 Spring 创建并放入 ioc 容器

格式:

1
2
3
4
@Configuration
@Import(OtherClassName.class)
public class ClassName {
}

说明:

  • @Import 注解在同一个类上,仅允许添加一次,如果需要导入多个,使用数组的形式进行设定
  • 在被导入的类中可以继续使用 @Import 导入其他资源
  • @Bean 所在的类可以使用导入的形式进入 Spring 容器,无需声明为 bean

Druid
  • 加载资源

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @Component
    public class JDBCConfig {
    @Bean("dataSource")
    public static DruidDataSource getDataSource() {
    DruidDataSource ds = new DruidDataSource();
    ds.setDriverClassName("com.mysql.jdbc.Driver");
    ds.setUrl("jdbc:mysql://192.168.2.185:3306/spring_db");
    ds.setUsername("root");
    ds.setPassword("123456");
    return ds;
    }
    }
  • 导入资源

    1
    2
    3
    4
    5
    @Configuration
    @ComponentScan(value = {"service","dao"})
    @Import(JDBCConfig.class)
    public class SpringConfig {
    }
  • 测试

    1
    2
    DruidDataSource dataSource = (DruidDataSource) ctx.getBean("dataSource");
    System.out.println(dataSource);

Junit

Spring 接管 Junit 的运行权,使用 Spring 专用的 Junit 类加载器,为 Junit 测试用例设定对应的 Spring 容器

注意:

  • 从 Spring5.0 以后,要求 Junit 的版本必须是4.12及以上

  • Junit 仅用于单元测试,不能将 Junit 的测试类配置成 Spring 的 bean,否则该配置将会被打包进入工程中

test / java / service / UserServiceTest

1
2
3
4
5
6
7
8
9
10
11
12
13
//设定spring专用的类加载器
@RunWith(SpringJUnit4ClassRunner.class)
//设定加载的spring上下文对应的配置
@ContextConfiguration(classes = SpringConfig.class)
public class UserServiceTest {
@Autowired
private AccountService accountService;
@Test
public void testFindById() {
Account account = accountService.findById(1);
Assert.assertEquals("Mike", account.getName());
}
}

pom.xml

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>5.1.9.RELEASE</version>
</dependency>

IoC原理

核心类

BeanFactory

ApplicationContext:

  1. ApplicationContext 是一个接口,提供了访问 Spring 容器的 API

  2. ClassPathXmlApplicationContext 是一个类,实现了上述功能

  3. ApplicationContext 的顶层接口是 BeanFactory

  4. BeanFactory 定义了 bean 相关的最基本操作

  5. ApplicationContext 在 BeanFactory 基础上追加了若干新功能

ApplicationContext 和 BeanFactory对比:

  • BeanFactory 和 ApplicationContext 是 Spring 的两大核心接口,都可以当做 Spring 的容器

  • BeanFactory 是 Spring 里面最底层的接口,是 IoC 的核心,定义了 IoC 的基本功能,包含了各种 Bean 的定义、加载、实例化,依赖注入和生命周期管理。ApplicationContext 接口作为 BeanFactory 的子类,除了提供 BeanFactory 所具有的功能外,还提供了更完整的框架功能:

    • 继承 MessageSource,因此支持国际化
    • 资源文件访问,如 URL 和文件(ResourceLoader)。
    • 载入多个(有继承关系)上下文(即加载多个配置文件) ,使得每一个上下文都专注于一个特定的层次,比如应用的 web 层
    • 提供在监听器中注册 bean 的事件
  • BeanFactory 创建的 bean 采用延迟加载形式,只有在使用到某个 Bean 时(调用 getBean),才对该 Bean 进行加载实例化(Spring 早期使用该方法获取 bean),这样就不能提前发现一些存在的 Spring 的配置问题;ApplicationContext 是在容器启动时,一次性创建了所有的 Bean,容器启动时,就可以发现 Spring 中存在的配置错误,这样有利于检查所依赖属性是否注入

  • ApplicationContext 启动后预载入所有的单实例 Bean,所以程序启动慢,运行时速度快

  • 两者都支持 BeanPostProcessor、BeanFactoryPostProcessor 的使用,但两者之间的区别是:BeanFactory 需要手动注册,而 ApplicationContext 则是自动注册

FileSystemXmlApplicationContext:加载文件系统中任意位置的配置文件,而 ClassPathXmlAC 只能加载类路径下的配置文件

BeanFactory 的成员属性:

1
String FACTORY_BEAN_PREFIX = "&";
  • 区分是 FactoryBean 还是创建的 Bean,加上 & 代表是工厂,getBean 将会返回工厂
  • FactoryBean:如果某个 bean 的配置非常复杂,或者想要使用编码的形式去构建它,可以提供一个构建该 bean 实例的工厂,这个工厂就是 FactoryBean 接口实现类,FactoryBean 接口实现类也是需要 Spring 管理
    • 这里产生两种对象,一种是 FactoryBean 接口实现类(IOC 管理),另一种是 FactoryBean 接口内部管理的对象
    • 获取 FactoryBean 接口实现类,使用 getBean 时传的 beanName 需要带 & 开头
    • 获取 FactoryBean 内部管理的对象,不需要带 & 开头

BeanFactory 的基本使用:

1
2
3
Resource res = new ClassPathResource("applicationContext.xml");
BeanFactory bf = new XmlBeanFactory(res);
UserService userService = (UserService)bf.getBean("userService");

FactoryBean

FactoryBean:对单一的 bean 的初始化过程进行封装,达到简化配置的目的

FactoryBean与 BeanFactory 区别:

  • FactoryBean:封装单个 bean 的创建过程,就是工厂的 Bean

  • BeanFactory:Spring 容器顶层接口,定义了 bean 相关的获取操作

代码实现:

  • FactoryBean,实现类一般是 MapperFactoryBean,创建 DAO 层接口的实现类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public class EquipmentDaoImplFactoryBean implements FactoryBean {
    @Override //获取Bean
    public Object getObject() throws Exception {
    return new EquipmentDaoImpl();
    }

    @Override //获取bean的类型
    public Class<?> getObjectType() {
    return null;
    }

    @Override //是否单例
    public boolean isSingleton() {
    return false;
    }
    }
  • MapperFactoryBean 继承 SqlSessionDaoSupport,可以获取 SqlSessionTemplate,完成 MyBatis 的整合

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public abstract class SqlSessionDaoSupport extends DaoSupport {
    private SqlSessionTemplate sqlSessionTemplate;
    // 获取 SqlSessionTemplate 对象
    public void setSqlSessionFactory(SqlSessionFactory sqlSessionFactory) {
    if (this.sqlSessionTemplate == null ||
    sqlSessionFactory != this.sqlSessionTemplate.getSqlSessionFactory()) {
    this.sqlSessionTemplate = createSqlSessionTemplate(sqlSessionFactory);
    }
    }
    }

过滤器

数据准备
  • DAO 层 UserDao、AccountDao、BookDao、EquipmentDao

    1
    2
    3
    public interface UserDao {
    public void save();
    }
    1
    2
    3
    4
    5
    6
    7
    @Component("userDao")
    public class UserDaoImpl implements UserDao {
    public void save() {
    System.out.println("user dao running...");
    }

    }
  • Service 业务层

    1
    2
    3
    public interface UserService {
    public void save();
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @Service("userService")
    public class UserServiceImpl implements UserService {
    @Autowired
    private UserDao userDao;//...........BookDao等

    public void save() {
    System.out.println("user service running...");
    userDao.save();
    }
    }

过滤器

名称:TypeFilter

类型:接口

作用:自定义类型过滤器

示例:

  • config / filter / MyTypeFilter

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    public class MyTypeFilter implements TypeFilter {
    @Override
    /**
    * metadataReader:读取到的当前正在扫描的类的信息
    * metadataReaderFactory:可以获取到任何其他类的信息
    */
    //加载的类满足要求,匹配成功
    public boolean match(MetadataReader metadataReader, MetadataReaderFactory metadataReaderFactory) throws IOException {
    //获取当前类注解的信息
    AnnotationMetadata am = metadataReader.getAnnotationMetadata();
    //获取当前正在扫描的类的类信息
    ClassMetadata classMetadata = metadataReader.getClassMetadata();
    //获取当前类资源(类的路径)
    Resource resource = metadataReader.getResource();


    //通过类的元数据获取类的名称
    String className = classMetadata.getClassName();
    //如果加载的类名满足过滤器要求,返回匹配成功
    if(className.equals("service.impl.UserServiceImpl")){
    //返回true表示匹配成功,返回false表示匹配失败。此处仅确认匹配结果,不会确认是排除还是加入,排除/加入由配置项决定,与此处无关
    return true;
    }
    return false;
    }
    }
  • SpringConfig

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @Configuration
    //设置排除bean,排除的规则是自定义规则(FilterType.CUSTOM),具体的规则定义为MyTypeFilter
    @ComponentScan(
    value = {"dao","service"},
    excludeFilters = @ComponentScan.Filter(
    type= FilterType.CUSTOM,
    classes = MyTypeFilter.class
    )
    )
    public class SpringConfig {
    }

导入器

bean 只有通过配置才可以进入 Spring 容器,被 Spring 加载并控制

  • 配置 bean 的方式如下:

    • XML 文件中使用 标签配置
    • 使用 @Component 及衍生注解配置

导入器可以快速高效导入大量 bean,替代 @Import({a.class,b.class}),无需在每个类上添加 @Bean

名称: ImportSelector

类型:接口

作用:自定义bean导入器

  • selector / MyImportSelector

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public class MyImportSelector implements ImportSelector{
    @Override
    public String[] selectImports(AnnotationMetadata importingClassMetadata) {
    // 1.编程形式加载一个类
    // return new String[]{"dao.impl.BookDaoImpl"};

    // 2.加载import.properties文件中的单个类名
    // ResourceBundle bundle = ResourceBundle.getBundle("import");
    // String className = bundle.getString("className");

    // 3.加载import.properties文件中的多个类名
    ResourceBundle bundle = ResourceBundle.getBundle("import");
    String className = bundle.getString("className");
    return className.split(",");
    }
    }
  • import.properties

    1
    2
    3
    4
    5
    6
    7
    8
    #2.加载import.properties文件中的单个类名
    #className=dao.impl.BookDaoImpl

    #3.加载import.properties文件中的多个类名
    #className=dao.impl.BookDaoImpl,dao.impl.AccountDaoImpl

    #4.导入包中的所有类
    path=dao.impl.*
  • SpringConfig

    1
    2
    3
    4
    5
    @Configuration
    @ComponentScan({"dao","service"})
    @Import(MyImportSelector.class)
    public class SpringConfig {
    }

注册器

可以取代 ComponentScan 扫描器

名称:ImportBeanDefinitionRegistrar

类型:接口

作用:自定义 bean 定义注册器

  • registrar / MyImportBeanDefinitionRegistrar

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    public class MyImportBeanDefinitionRegistrar implements ImportBeanDefinitionRegistrar {
    /**
    * AnnotationMetadata:当前类的注解信息
    * BeanDefinitionRegistry:BeanDefinition注册类,把所有需要添加到容器中的bean调用registerBeanDefinition手工注册进来
    */
    @Override
    public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) {
    //自定义注册器
    //1.开启类路径bean定义扫描器,需要参数bean定义注册器BeanDefinitionRegistry,需要制定是否使用默认类型过滤器
    ClassPathBeanDefinitionScanner scanner = new ClassPathBeanDefinitionScanner(registry,false);
    //2.添加包含性加载类型过滤器(可选,也可以设置为排除性加载类型过滤器)
    scanner.addIncludeFilter(new TypeFilter() {
    @Override
    public boolean match(MetadataReader metadataReader, MetadataReaderFactory metadataReaderFactory) throws IOException {
    //所有匹配全部成功,此处应该添加实际的业务判定条件
    return true;
    }
    });
    //设置扫描路径
    scanner.addExcludeFilter(tf);//排除
    scanner.scan("dao","service");
    }
    }
  • SpringConfig

    1
    2
    3
    4
    @Configuration
    @Import(MyImportBeanDefinitionRegistrar.class)
    public class SpringConfig {
    }

处理器

通过创建类继承相应的处理器的接口,重写后置处理的方法,来实现拦截 Bean 的生命周期来实现自己自定义的逻辑

BeanPostProcessor:bean 后置处理器,bean 创建对象初始化前后进行拦截工作的

BeanFactoryPostProcessor:beanFactory 的后置处理器

  •     加载时机:在 BeanFactory 初始化之后调用,来定制和修改 BeanFactory 的内容;所有的 bean 定义已经保存加载到 beanFactory,但是 bean 的实例还未创建
    
  •     执行流程:
    
    • ioc 容器创建对象
    • invokeBeanFactoryPostProcessors(beanFactory):执行 BeanFactoryPostProcessor
      • 在 BeanFactory 中找到所有类型是 BeanFactoryPostProcessor 的组件,并执行它们的方法
      • 在初始化创建其他组件前面执行

BeanDefinitionRegistryPostProcessor:

  • 加载时机:在所有 bean 定义信息将要被加载,但是 bean 实例还未创建,优先于 BeanFactoryPostProcessor 执行;利用 BeanDefinitionRegistryPostProcessor 给容器中再额外添加一些组件

  • 执行流程:

    • ioc 容器创建对象
    • refresh() → invokeBeanFactoryPostProcessors(beanFactory)
    • 从容器中获取到所有的 BeanDefinitionRegistryPostProcessor 组件
      • 依次触发所有的 postProcessBeanDefinitionRegistry() 方法
      • 再来触发 postProcessBeanFactory() 方法

监听器

基本概述

ApplicationListener:监听容器中发布的事件,完成事件驱动模型开发

1
public interface ApplicationListener<E extends ApplicationEvent>

所以监听 ApplicationEvent 及其下面的子事件

应用监听器步骤:

  •   写一个监听器(ApplicationListener实现类)来监听某个事件(ApplicationEvent及其子类)
    
  •   把监听器加入到容器 @Component
    
  •   只要容器中有相关事件的发布,就能监听到这个事件;
    * 	  ContextRefreshedEvent:容器刷新完成(所有 bean 都完全创建)会发布这个事件
    * 	  ContextClosedEvent:关闭容器会发布这个事件
    
  •   发布一个事件:`applicationContext.publishEvent()`
    
1
2
3
4
5
6
7
8
@Component
public class MyApplicationListener implements ApplicationListener<ApplicationEvent> {
//当容器中发布此事件以后,方法触发
@Override
public void onApplicationEvent(ApplicationEvent event) {
System.out.println("收到事件:" + event);
}
}

实现原理

ContextRefreshedEvent 事件:

  • 容器初始化过程中执行 initApplicationEventMulticaster():初始化事件多播器

    • 先去容器中查询 id = applicationEventMulticaster 的组件,有直接返回
    • 没有就执行 this.applicationEventMulticaster = new SimpleApplicationEventMulticaster(beanFactory) 并且加入到容器中
    • 以后在其他组件要派发事件,自动注入这个 applicationEventMulticaster
  • 容器初始化过程执行 registerListeners() 注册监听器

    • 从容器中获取所有监听器:getBeanNamesForType(ApplicationListener.class, true, false)
    • 将 listener 注册到 ApplicationEventMulticaster
  • 容器刷新完成:finishRefresh() → publishEvent(new ContextRefreshedEvent(this))

    发布 ContextRefreshedEvent 事件:

    • 获取事件的多播器(派发器):getApplicationEventMulticaster()
    • multicastEvent 派发事件
      • 获取到所有的 ApplicationListener
      • 遍历 ApplicationListener
        • 如果有 Executor,可以使用 Executor 异步派发 Executor executor = getTaskExecutor()
        • 没有就同步执行 listener 方法 invokeListener(listener, event),拿到 listener 回调 onApplicationEvent

容器关闭会发布 ContextClosedEvent


注解实现

注解:@EventListener

基本使用:

1
2
3
4
5
6
7
@Service
public class UserService{
@EventListener(classes={ApplicationEvent.class})
public void listen(ApplicationEvent event){
System.out.println("UserService。。监听到的事件:" + event);
}
}

原理:使用 EventListenerMethodProcessor 处理器来解析方法上的 @EventListener,Spring 扫描使用注解的方法,并为之创建一个监听对象

SmartInitializingSingleton 原理:afterSingletonsInstantiated()

  •     IOC 容器创建对象并 refresh()
    
  •     finishBeanFactoryInitialization(beanFactory):初始化剩下的单实例 bean
    
    • 先创建所有的单实例 bean:getBean()
    • 获取所有创建好的单实例 bean,判断是否是 SmartInitializingSingleton 类型的,如果是就调用 afterSingletonsInstantiated()

AOP

基本概述

AOP(Aspect Oriented Programing):面向切面编程,一种编程范式,指导开发者如何组织程序结构

AOP 弥补了 OOP 的不足,基于 OOP 基础之上进行横向开发:

  • uOOP 规定程序开发以类为主体模型,一切围绕对象进行,完成某个任务先构建模型

  • uAOP 程序开发主要关注基于 OOP 开发中的共性功能,一切围绕共性功能进行,完成某个任务先构建可能遇到的所有共性功能(当所有功能都开发出来也就没有共性与非共性之分),将软件开发由手工制作走向半自动化/全自动化阶段,实现“插拔式组件体系结构”搭建

AOP 作用:

  • 提高代码的可重用性

  • 业务代码编码更简洁

  • 业务代码维护更高效

  • 业务功能扩展更便捷


核心概念

概念详解

  • Joinpoint(连接点):就是方法

  • Pointcut(切入点):就是挖掉共性功能的方法

  • Advice(通知):就是共性功能,最终以一个方法的形式呈现

  • Aspect(切面):就是共性功能与挖的位置的对应关系

  • Target(目标对象):就是挖掉功能的方法对应的类产生的对象,这种对象是无法直接完成最终工作的

  • Weaving(织入):就是将挖掉的功能回填的动态过程

  • Proxy(代理):目标对象无法直接完成工作,需要对其进行功能回填,通过创建原始对象的代理对象实现

  • Introduction(引入/引介):就是对原始对象无中生有的添加成员变量或成员方法


入门项目

开发步骤:

  • 开发阶段

    • 制作程序

    • 将非共性功能开发到对应的目标对象类中,并制作成切入点方法

    • 将共性功能独立开发出来,制作成通知

    • 在配置文件中,声明切入点

    • 在配置文件中,声明切入点与通知间的关系(含通知类型),即切面

  • 运行阶段(AOP 完成)

    • Spring 容器加载配置文件,监控所有配置的切入点方法的执行

    • 当监控到切入点方法被运行,使用代理机制,动态创建目标对象的代理对象,根据通知类别,在代理对象的对应位置将通知对应的功能织入,完成完整的代码逻辑并运行

  1. 导入坐标 pom.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context</artifactId>
    <version>5.1.9.RELEASE</version>
    </dependency>
    <dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
    <version>1.9.4</version>
    </dependency>
  2. 业务层抽取通用代码 service / UserServiceImpl

    1
    2
    3
    public interface UserService {
    public void save();
    }
    1
    2
    3
    4
    5
    6
    7
    public class UserServiceImpl implements UserService {
    @Override
    public void save() {
    //System.out.println("共性功能");
    System.out.println("user service running...");
    }
    }

    aop.AOPAdvice

    1
    2
    3
    4
    5
    6
    7
    //1.制作通知类,在类中定义一个方法用于完成共性功能
    public class AOPAdvice {
    //共性功能抽取后职称独立的方法
    public void function(){
    System.out.println("共性功能");
    }
    }
  3. 把通知加入spring容器管理,配置aop applicationContext.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    <?xml version="1.0" encoding="UTF-8"?>
    <beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:context="http://www.springframework.org/schema/context"
    xmlns:aop="http://www.springframework.org/schema/aop"
    xsi:schemaLocation="
    http://www.springframework.org/schema/beans
    https://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/context
    https://www.springframework.org/schema/context/spring-context.xsd
    http://www.springframework.org/schema/aop
    https://www.springframework.org/schema/aop/spring-aop.xsd
    ">
    <!--原始Spring控制资源-->
    <bean id="userService" class= "service.impl.UserServiceImpl"/>
    <!--2.配置共性功能成功spring控制的资源-->
    <bean id="myAdvice" class="aop.AOPAdvice"/>
    <!--3.开启AOP命名空间: beans标签内-->
    <!--4.配置AOP-->
    <aop:config>
    <!--5.配置切入点-->
    <aop:pointcut id="pt" expression="execution(* *..*(..))"/>
    <!--6.配置切面(切入点与通知的关系)-->
    <aop:aspect ref="myAdvice">
    <!--7.配置具体的切入点对应通知中那个操作方法-->
    <aop:before method="function" pointcut-ref="pt"/>
    </aop:aspect>
    </aop:config>
    </beans>
  4. 测试类

    1
    2
    3
    4
    5
    6
    7
    public class App {
    public static void main(String[] args) {
    ApplicationContext ctx = new ClassPathXmlApplicationContext("applicationContext.xml");
    UserService userService = (UserService) ctx.getBean("userService");
    userService.save();//先输出共性功能,然后 user service running...
    }
    }

XML开发

AspectJ

Aspect(切面)用于描述切入点与通知间的关系,是 AOP 编程中的一个概念

AspectJ 是基于 java 语言对 Aspect 的实现


AOP

config

标签:aop:config 的子标签

作用:设置 AOP

格式:

1
2
3
4
5
<beans>
<aop:config>……</aop:config>
<aop:config>……</aop:config>
<!--一个beans标签中可以配置多个aop:config标签-->
</beans>

pointcut

标签:aop:pointcut,归属于 aop:config 标签和 aop:aspect 标签

作用:设置切入点

格式:

1
2
3
4
5
6
<aop:config>
<aop:pointcut id="pointcutId" expression="……"/>
<aop:aspect>
<aop:pointcut id="pointcutId" expression="……"/>
</aop:aspect>
</aop:config>

说明:

  • 一个 aop:config 标签中可以配置多个 aop:pointcut 标签,且该标签可以配置在 aop:aspect 标签内

属性:

  • id :识别切入点的名称

  • expression :切入点表达式


aspect

标签:aop:aspect,aop:config 的子标签

作用:设置具体的 AOP 通知对应的切入点(切面)

格式:

1
2
3
4
5
<aop:config>
<aop:aspect ref="beanId">……</aop:aspect>
<aop:aspect ref="beanId">……</aop:aspect>
<!--一个aop:config标签中可以配置多个aop:aspect标签-->
</aop:config>

属性:

  • ref :通知所在的 bean 的 id

Pointcut

切入点

切入点描述的是某个方法

切入点表达式是一个快速匹配方法描述的通配格式,类似于正则表达式


表达式

格式:

1
关键字(访问修饰符  返回值  包名.类名.方法名(参数)异常名)

示例:

1
2
//匹配UserService中只含有一个参数的findById方法
execution(public User service.UserService.findById(int))

格式解析:

  • 关键字:描述表达式的匹配模式(参看关键字列表)
  • 访问修饰符:方法的访问控制权限修饰符
  • 类名:方法所在的类(此处可以配置接口名称)
  • 异常:方法定义中指定抛出的异常

关键字:

  • execution :匹配执行指定方法

  • args :匹配带有指定参数类型的方法

  • within、this、target、@within、@target、@args、@annotation、bean、reference pointcut等

通配符:

  • *:单个独立的任意符号,可以独立出现,也可以作为前缀或者后缀的匹配符出现

    1
    2
    //匹配com.seazean包下的任意包中的UserService类或接口中所有find开头的带有一个任意参数的方法
    execution(public * com.seazean.*.UserService.find*(*)
  • .. :多个连续的任意符号,可以独立出现,常用于简化包名与参数

    1
    2
    //匹配com包下的任意包中的UserService类或接口中所有名称为findById参数任意数量和类型的方法
    execution(public User com..UserService.findById(..))
  • +:专用于匹配子类类型

    1
    2
    //匹配任意包下的Service结尾的类或者接口的子类或者实现类
    execution(* *..*Service+.*(..))

逻辑运算符:

  • &&:连接两个切入点表达式,表示两个切入点表达式同时成立的匹配
  • ||:连接两个切入点表达式,表示两个切入点表达式成立任意一个的匹配
  • ! :连接单个切入点表达式,表示该切入点表达式不成立的匹配

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
execution(* *(..))		//前三个都是匹配全部
execution(* *..*(..))
execution(* *..*.*(..))
execution(public * *..*.*(..))
execution(public int *..*.*(..))
execution(public void *..*.*(..))
execution(public void com..*.*(..))
execution(public void com..service.*.*(..))
execution(public void com.seazean.service.*.*(..))
execution(public void com.seazean.service.User*.*(..))
execution(public void com.seazean.service.*Service.*(..))
execution(public void com.seazean.service.UserService.*(..))
execution(public User com.seazean.service.UserService.find*(..)) //find开头
execution(public User com.seazean.service.UserService.*Id(..)) //I
execution(public User com.seazean.service.UserService.findById(..))
execution(public User com.seazean.service.UserService.findById(int))
execution(public User com.seazean.service.UserService.findById(int,int))
execution(public User com.seazean.service.UserService.findById(int,*))
execution(public User com.seazean.service.UserService.findById())
execution(List com.seazean.service.*Service+.findAll(..))

配置方式

XML 配置规则:

  • 企业开发命名规范严格遵循规范文档进行

  • 先为方法配置局部切入点,再抽取类中公共切入点,最后抽取全局切入点

  • 代码走查过程中检测切入点是否存在越界性包含

  • 代码走查过程中检测切入点是否存在非包含性进驻

  • 设定 AOP 执行检测程序,在单元测试中监控通知被执行次数与预计次数是否匹配(不绝对正确:加进一个不该加的,删去一个不该删的相当于结果不变)

  • 设定完毕的切入点如果发生调整务必进行回归测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<aop:config>
<!--1.配置公共切入点-->
<aop:pointcut id="pt1" expression="execution(* *(..))"/>
<aop:aspect ref="myAdvice">
<!--2.配置局部切入点-->
<aop:pointcut id="pt2" expression="execution(* *(..))"/>
<!--引用公共切入点-->
<aop:before method="logAdvice" pointcut-ref="pt1"/>
<!--引用局部切入点-->
<aop:before method="logAdvice" pointcut-ref="pt2"/>
<!--3.直接配置切入点-->
<aop:before method="logAdvice" pointcut="execution(* *(..))"/>
</aop:aspect>
</aop:config>

Advice

通知类型

AOP 的通知类型共5种:前置通知,后置通知、返回后通知、抛出异常后通知、环绕通知

before

标签:aop:before,aop:aspect的子标签

作用:设置前置通知

  • 前置通知:原始方法执行前执行,如果通知中抛出异常,阻止原始方法运行
  • 应用:数据校验

格式:

1
2
3
4
<aop:aspect ref="adviceId">
<aop:before method="methodName" pointcut="execution(* *(..))"/>
<!--一个aop:aspect标签中可以配置多个aop:before标签-->
</aop:aspect>

基本属性:

  • method:在通知类中设置当前通知类别对应的方法

  • pointcut:设置当前通知对应的切入点表达式,与pointcut-ref属性冲突

  • pointcut-ref:设置当前通知对应的切入点id,与pointcut属性冲突

after

标签:aop:after,aop:aspect的子标签

作用:设置后置通知

  • 后置通知:原始方法执行后执行,无论原始方法中是否出现异常,都将执行通知

  • 应用:现场清理

格式:

1
2
3
4
<aop:aspect ref="adviceId">
<aop:after method="methodName" pointcut="execution(* *(..))"/>
<!--一个aop:aspect标签中可以配置多个aop:after标签-->
</aop:aspect>

基本属性:

  • method:在通知类中设置当前通知类别对应的方法

  • pointcut:设置当前通知对应的切入点表达式,与pointcut-ref属性冲突

  • pointcut-ref:设置当前通知对应的切入点id,与pointcut属性冲突

after-r

标签:aop:after-returning,aop:aspect的子标签

作用:设置返回后通知

  • 返回后通知:原始方法正常执行完毕并返回结果后执行,如果原始方法中抛出异常,无法执行

  • 应用:返回值相关数据处理

格式:

1
2
3
4
<aop:aspect ref="adviceId">
<aop:after-returning method="methodName" pointcut="execution(* *(..))"/>
<!--一个aop:aspect标签中可以配置多个aop:after-returning标签-->
</aop:aspect>

基本属性:

  • method:在通知类中设置当前通知类别对应的方法
  • pointcut:设置当前通知对应的切入点表达式,与pointcut-ref属性冲突
  • pointcut-ref:设置当前通知对应的切入点id,与pointcut属性冲突
  • returning:设置接受返回值的参数,与通知类中对应方法的参数一致
after-t

标签:aop:after-throwing,aop:aspect的子标签

作用:设置抛出异常后通知

  • 抛出异常后通知:原始方法抛出异常后执行,如果原始方法没有抛出异常,无法执行
  • 应用:对原始方法中出现的异常信息进行处理

格式:

1
2
3
4
<aop:aspect ref="adviceId">
<aop:after-throwing method="methodName" pointcut="execution(* *(..))"/>
<!--一个aop:aspect标签中可以配置多个aop:after-throwing标签-->
</aop:aspect>

基本属性:

  • method:在通知类中设置当前通知类别对应的方法
  • pointcut:设置当前通知对应的切入点表达式,与pointcut-ref属性冲突
  • pointcut-ref:设置当前通知对应的切入点id,与pointcut属性冲突
  • throwing:设置接受异常对象的参数,与通知类中对应方法的参数一致
around

标签:aop:around,aop:aspect的子标签

作用:设置环绕通知

  • 环绕通知:在原始方法执行前后均有对应执行执行,还可以阻止原始方法的执行

  • 应用:功能强大,可以做任何事情

格式:

1
2
3
4
<aop:aspect ref="adviceId">
<aop:around method="methodName" pointcut="execution(* *(..))"/>
<!--一个aop:aspect标签中可以配置多个aop:around标签-->
</aop:aspect>

基本属性:

  • method :在通知类中设置当前通知类别对应的方法

  • pointcut :设置当前通知对应的切入点表达式,与pointcut-ref属性冲突

  • pointcut-ref :设置当前通知对应的切入点id,与pointcut属性冲突

环绕通知的开发方式(参考通知顺序章节):

  • 环绕通知是在原始方法的前后添加功能,在环绕通知中,存在对原始方法的显式调用

    1
    2
    3
    4
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
    Object ret = pjp.proceed();
    return ret;
    }
  • 环绕通知方法相关说明:

    • 方法须设定 Object 类型的返回值,否则会拦截原始方法的返回。如果原始方法返回值类型为 void,通知方法也可以设定返回值类型为 void,最终返回 null

    • 方法需在第一个参数位置设定 ProceedingJoinPoint 对象,通过该对象调用 proceed() 方法,实现对原始方法的调用。如省略该参数,原始方法将无法执行

    • 使用 proceed() 方法调用原始方法时,因无法预知原始方法运行过程中是否会出现异常,强制抛出 Throwable 对象,封装原始方法中可能出现的异常信息


通知顺序

当同一个切入点配置了多个通知时,通知会存在运行的先后顺序,该顺序以通知配置的顺序为准。

  • AOPAdvice

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    public class AOPAdvice {
    public void before(){
    System.out.println("before...);
    }
    public void after(){
    System.out.println("after...");
    }
    public void afterReturing(){
    System.out.println("afterReturing...");
    }
    public void afterThrowing(){
    System.out.println("afterThrowing...");
    }
    public Object around(ProceedingJoinPoint pjp) {
    System.out.println("around before...");
    //对原始方法的调用
    Object ret = pjp.proceed();
    System.out.println("around after..."+ret);
    return ret;
    }
    }
  • applicationContext.xml 顺序执行

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <aop:config>
    <aop:pointcut id="pt" expression="execution(* *..*(..))"/>
    <aop:aspect ref="myAdvice">
    <aop:before method="before" pointcut-ref="pt"/>
    <aop:after method="after" pointcut-ref="pt"/>
    <aop:after-returning method="afterReturing" pointcut-ref="pt"/>
    <aop:after-throwing method="afterThrowing" pointcut-ref="pt"/>
    <aop:around method="around" pointcut-ref="pt"/>
    </aop:aspect>
    </aop:config>

获取数据
参数

第一种方式:

  • 设定通知方法第一个参数为 JoinPoint,通过该对象调用 getArgs() 方法,获取原始方法运行的参数数组

    1
    2
    3
    public void before(JoinPoint jp) throws Throwable {
    Object[] args = jp.getArgs();
    }
  • 所有的通知均可以获取参数,环绕通知使用ProceedingJoinPoint.getArgs()方法

第二种方式:

  • 设定切入点表达式为通知方法传递参数(锁定通知变量名)

  • 流程图:

  • 解释:

    • &amp 代表并且 &
    • 输出结果:a = param1 b = param2

第三种方式:

  • 设定切入点表达式为通知方法传递参数(改变通知变量名的定义顺序)

  • 流程图:

  • 解释:输出结果 a = param2 b = param1


返回值

环绕通知和返回后通知可以获取返回值,后置通知不一定,其他类型获取不到

第一种方式:适用于返回后通知(after-returning)

  • 设定返回值变量名

  • 原始方法:

    1
    2
    3
    4
    5
    6
    7
    public class UserServiceImpl implements UserService {
    @Override
    public int save() {
    System.out.println("user service running...");
    return 100;
    }
    }
  • AOP 配置:

    1
    2
    3
    4
    <aop:aspect ref="myAdvice">
    <aop:pointcut id="pt" expression="execution(* *(..))"/>
    <aop:after-returning method="afterReturning" pointcut-ref="pt" returning="ret"/>
    </aop:aspect>
  • 通知类:

    1
    2
    3
    4
    5
    public class AOPAdvice {
    public void afterReturning(Object ret) {
    System.out.println("return:" + ret);
    }
    }

第二种:适用于环绕通知(around)

  • 在通知类的方法中调用原始方法获取返回值

  • 原始方法:

    1
    2
    3
    4
    5
    6
    7
    public class UserServiceImpl implements UserService {
    @Override
    public int save() {
    System.out.println("user service running...");
    return 100;
    }
    }
  • AOP 配置:

    1
    2
    3
    4
    <aop:aspect ref="myAdvice">
    <aop:pointcut id="pt" expression="execution(* *(..)) "/>
    <aop:around method="around" pointcut-ref="pt" />
    </aop:aspect>
  • 通知类:

    1
    2
    3
    4
    5
    6
    public class AOPAdvice {    
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
    Object ret = pjp.proceed();
    return ret;
    }
    }
  • 测试类:

    1
    2
    3
    4
    5
    6
    7
    8
    public class App {
    public static void main(String[] args) {
    ApplicationContext ctx = new ClassPathXmlApplicationContext("applicationContext.xml");
    UserService userService = (UserService) ctx.getBean("userService");
    int ret = userService.save();
    System.out.println("app....." + ret);
    }
    }

异常

环绕通知和抛出异常后通知可以获取异常,后置通知不一定,其他类型获取不到

第一种:适用于返回后通知(after-throwing)

  • 设定异常对象变量名

  • 原始方法

    1
    2
    3
    4
    5
    6
    7
    public class UserServiceImpl implements UserService {
    @Override
    public void save() {
    System.out.println("user service running...");
    int i = 1/0;
    }
    }
  • AOP 配置

    1
    2
    3
    4
    <aop:aspect ref="myAdvice">
    <aop:pointcut id="pt" expression="execution(* *(..)) "/>
    <aop:after-throwing method="afterThrowing" pointcut-ref="pt" throwing="t"/>
    </aop:aspect>
  • 通知类

    1
    2
    3
    public void afterThrowing(Throwable t){
    System.out.println(t.getMessage());
    }

第二种:适用于环绕通知(around)

  • 在通知类的方法中调用原始方法捕获异常
  • 原始方法:

    1
    2
    3
    4
    5
    6
    7
    public class UserServiceImpl implements UserService {
    @Override
    public void save() {
    System.out.println("user service running...");
    int i = 1/0;
    }
    }
  • AOP 配置:

    1
    2
    3
    4
    <aop:aspect ref="myAdvice">
    <aop:pointcut id="pt" expression="execution(* *(..)) "/>
    <aop:around method="around" pointcut-ref="pt" />
    </aop:aspect>
  • 通知类:try……catch……捕获异常后,ret为null

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
    Object ret = pjp.proceed(); //对此处调用进行try……catch……捕获异常,或抛出异常
    /* try {
    ret = pjp.proceed();
    } catch (Throwable throwable) {
    System.out.println("around exception..." + throwable.getMessage());
    }*/
    return ret;
    }
  • 测试类

    1
    userService.delete();

获取全部
  • UserService

    1
    2
    3
    4
    5
    6
    7
    public interface UserService {
    public void save(int i, int m);

    public int update();

    public void delete();
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    public class UserServiceImpl implements UserService {
    @Override
    public void save(int i, int m) {
    System.out.println("user service running..." + i + "," + m);
    }

    @Override
    public int update() {
    System.out.println("user service update running...");
    return 100;
    }

    @Override
    public void delete() {
    System.out.println("user service delete running...");
    int i = 1 / 0;
    }
    }
  • AOPAdvice

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    public class AOPAdvice {
    public void before(JoinPoint jp){
    //通过JoinPoint参数获取调用原始方法所携带的参数
    Object[] args = jp.getArgs();
    System.out.println("before..."+args[0]);
    }

    public void after(JoinPoint jp){
    Object[] args = jp.getArgs();
    System.out.println("after..."+args[0]);
    }

    public void afterReturing(Object ret){
    System.out.println("afterReturing..."+ret);
    }

    public void afterThrowing(Throwable t){
    System.out.println("afterThrowing..."+t.getMessage());
    }

    public Object around(ProceedingJoinPoint pjp) {
    System.out.println("around before...");
    Object ret = null;
    try {
    //对原始方法的调用
    ret = pjp.proceed();
    } catch (Throwable throwable) {
    System.out.println("around...exception...."+throwable.getMessage());
    }
    System.out.println("around after..."+ret);
    return ret;
    }
    }
  • applicationContext.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    <?xml version="1.0" encoding="UTF-8"?>
    <beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:context="http://www.springframework.org/schema/context"
    xmlns:aop="http://www.springframework.org/schema/aop"
    xsi:schemaLocation="
    http://www.springframework.org/schema/beans
    https://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/context
    https://www.springframework.org/schema/context/spring-context.xsd
    http://www.springframework.org/schema/aop
    https://www.springframework.org/schema/aop/spring-aop.xsd
    ">
    <bean id="userService" class="service.impl.UserServiceImpl"/>
    <bean id="myAdvice" class="aop.AOPAdvice"/>

    <aop:config>
    <aop:pointcut id="pt" expression="execution(* *..*(..))"/>
    <aop:aspect ref="myAdvice">
    <aop:before method="before" pointcut="pt"/>
    <aop:around method="around" pointcut-ref="pt"/>
    <aop:after method="after" pointcut="pt"/>
    <aop:after-returning method="afterReturning" pointcut-ref="pt" returning="ret"/>
    <aop:after-throwing method="afterThrowing" pointcut-ref="pt" throwing="t"/>
    </aop:aspect>
    </aop:config>
    </beans>
  • 测试类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public class App {
    public static void main(String[] args) {
    ApplicationContext ctx = new ClassPathXmlApplicationContext("applicationContext.xml");
    UserService userService = (UserService) ctx.getBean("userService");
    // userService.save(666, 888);
    // int ret = userService.update();
    // System.out.println("app....." + ret);
    userService.delete();
    }
    }

注解开发

AOP注解

AOP 注解简化 XML:

注意事项:

  1. 切入点最终体现为一个方法,无参无返回值,无实际方法体内容,但不能是抽象方法

  2. 引用切入点时必须使用方法调用名称,方法后面的 () 不能省略

  3. 切面类中定义的切入点只能在当前类中使用,如果想引用其他类中定义的切入点使用“类名.方法名()”引用

  4. 可以在通知类型注解后添加参数,实现 XML 配置中的属性,例如 after-returning 后的 returning 性


启动注解

XML

开启 AOP 注解支持:

1
2
<aop:aspectj-autoproxy/>
<context:component-scan base-package="aop,config,service"/><!--启动Spring扫描-->

开发步骤:

  1. 导入坐标(伴随 spring-context 坐标导入已经依赖导入完成)
  2. 开启 AOP 注解支持
  3. 配置切面 @Aspect
  4. 定义专用的切入点方法,并配置切入点 @Pointcut
  5. 为通知方法配置通知类型及对应切入点 @Before
纯注解

注解:@EnableAspectJAutoProxy

位置:Spring 注解配置类定义上方

作用:设置当前类开启 AOP 注解驱动的支持,加载 AOP 注解

格式:

1
2
3
4
5
@Configuration
@ComponentScan("com.seazean")
@EnableAspectJAutoProxy
public class SpringConfig {
}

基本注解

Aspect

注解:@Aspect

位置:类定义上方

作用:设置当前类为切面类

格式:

1
2
3
@Aspect
public class AopAdvice {
}
Pointcut

注解:@Pointcut

位置:方法定义上方

作用:使用当前方法名作为切入点引用名称

格式:

1
2
3
@Pointcut("execution(* *(..))")
public void pt() {
}

说明:被修饰的方法忽略其业务功能,格式设定为无参无返回值的方法,方法体内空实现(非抽象)

Before

注解:@Before

位置:方法定义上方

作用:标注当前方法作为前置通知

格式:

1
2
3
4
@Before("pt()")
public void before(JoinPoint joinPoint){
//joinPoint.getArgs();
}

注意:多个参数时,JoinPoint参数一定要在第一位

After

注解:@After

位置:方法定义上方

作用:标注当前方法作为后置通知

格式:

1
2
3
@After("pt()")
public void after(){
}
AfterR

注解:@AfterReturning

位置:方法定义上方

作用:标注当前方法作为返回后通知

格式:

1
2
3
@AfterReturning(value="pt()", returning = "result")
public void afterReturning(Object result) {
}

特殊参数:

  • returning :设定使用通知方法参数接收返回值的变量名
AfterT

注解:@AfterThrowing

位置:方法定义上方

作用:标注当前方法作为异常后通知

格式:

1
2
3
@AfterThrowing(value="pt()", throwing = "t")
public void afterThrowing(Throwable t){
}

特殊参数:

  • throwing :设定使用通知方法参数接收原始方法中抛出的异常对象名
Around

注解:@Around

位置:方法定义上方

作用:标注当前方法作为环绕通知

格式:

1
2
3
4
5
@Around("pt()")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
Object ret = pjp.proceed();
return ret;
}

执行顺序

AOP 使用 XML 配置情况下,通知的执行顺序由配置顺序决定,在注解情况下由于不存在配置顺序的概念,参照通知所配置的方法名字符串对应的编码值顺序,可以简单理解为字母排序

  • 同一个通知类中,相同通知类型以方法名排序为准

    1
    2
    3
    4
    5
    @Before("aop.AOPPointcut.pt()")
    public void aop001Log(){}

    @Before("aop.AOPPointcut.pt()")
    public void aop002Exception(){}
  • 不同通知类中,以类名排序为准

  • 使用 @Order 注解通过变更 bean 的加载顺序改变通知的加载顺序

    1
    2
    3
    4
    5
    @Component
    @Aspect
    @Order(1) //先执行
    public class AOPAdvice2 {
    }
    1
    2
    3
    4
    5
    @Component
    @Aspect
    @Order(2)
    public class AOPAdvice1 {//默认执行此通知
    }

AOP 原理

静态代理

装饰者模式(Decorator Pattern):在不惊动原始设计的基础上,为其添加功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class UserServiceDecorator implements UserService{
private UserService userService;

public UserServiceDecorator(UserService userService) {
this.userService = userService;
}

public void save() {
//原始调用
userService.save();
//增强功能(后置)
System.out.println("后置增强功能");
}
}

Proxy

JDKProxy 动态代理是针对对象做代理,要求原始对象具有接口实现,并对接口方法进行增强,因为代理类继承Proxy

静态代理和动态代理的区别:

  • 静态代理是在编译时就已经将接口、代理类、被代理类的字节码文件确定下来
  • 动态代理是程序在运行后通过反射创建字节码文件交由 JVM 加载
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class UserServiceJDKProxy {
public static UserService createUserServiceJDKProxy(UserService userService) {
UserService service = (UserService) Proxy.newProxyInstance(
userService.getClass().getClassLoader(),//获取被代理对象的类加载器
userService.getClass().getInterfaces(), //获取被代理对象实现的接口
new InvocationHandler() { //对原始方法执行进行拦截并增强
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (method.getName().equals("save")) {
System.out.println("前置增强");
Object ret = method.invoke(userService, args);
System.out.println("后置增强");
return ret;
}
return null;
}
});
return service;
}
}

CGLIB

CGLIB(Code Generation Library):Code 生成类库

CGLIB 特点:

  • CGLIB 动态代理不限定是否具有接口,可以对任意操作进行增强
  • CGLIB 动态代理无需要原始被代理对象,动态创建出新的代理对象
  • CGLIB 继承被代理类,如果代理类是 final 则不能实现

  • CGLIB 类

    • JDKProxy 仅对接口方法做增强,CGLIB 对所有方法做增强,包括 Object 类中的方法(toString、hashCode)
    • 返回值类型采用多态向下转型,所以需要设置父类类型

    需要对方法进行判断是否是 save,来选择性增强

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    public class UserServiceImplCglibProxy {
    public static UserService createUserServiceCglibProxy(Class cls){
    //1.创建Enhancer对象(可以理解为内存中动态创建了一个类的字节码)
    Enhancer enhancer = new Enhancer();

    //2.设置Enhancer对象的父类是指定类型UserServerImpl
    enhancer.setSuperclass(cls);

    //3.设置回调方法
    enhancer.setCallback(new MethodInterceptor() {
    @Override
    public Object intercept(Object o, Method m, Object[] args, MethodProxy mp) throws Throwable {
    //o是被代理出的类创建的对象,所以使用MethodProxy调用,并且是调用父类
    //通过调用父类的方法实现对原始方法的调用
    Object ret = methodProxy.invokeSuper(o, args);
    //后置增强内容,需要判断是都是save方法
    if (method.getName().equals("save")) {
    System.out.println("I love Java");
    }
    return ret;
    }
    });
    //使用Enhancer对象创建对应的对象
    return (UserService)enhancer.create();
    }
    }
  • Test类

    1
    2
    3
    4
    5
    6
    public class App {
    public static void main(String[] args) {
    UserService userService = UserServiceCglibProxy.createUserServiceCglibProxy(UserServiceImpl.class);
    userService.save();
    }
    }

代理选择

Spirng 可以通过配置的形式控制使用的代理形式,Spring 会先判断是否实现了接口,如果实现了接口就使用 JDK 动态代理,如果没有实现接口则使用 CGLIB 动态代理,通过配置可以修改为使用 CGLIB

  • XML 配置

    1
    2
    <!--XML配置AOP-->
    <aop:config proxy-target-class="false"></aop:config>
  • XML 注解支持

    1
    2
    <!--注解配置AOP-->
    <aop:aspectj-autoproxy proxy-target-class="false"/>
  • 注解驱动

    1
    2
    //修改为使用 cglib 创建代理对象
    @EnableAspectJAutoProxy(proxyTargetClass = true)
  • JDK 动态代理和 CGLIB 动态代理的区别:

    • JDK 动态代理只能对实现了接口的类生成代理,没有实现接口的类不能使用。
    • CGLIB 动态代理即使被代理的类没有实现接口也可以使用,因为 CGLIB 动态代理是使用继承被代理类的方式进行扩展
    • CGLIB 动态代理是通过继承的方式,覆盖被代理类的方法来进行代理,所以如果方法是被 final 修饰的话,就不能进行代理

织入时机

AOP织入时机


事务

事务机制

事务介绍

事务:数据库中多个操作合并在一起形成的操作序列,事务特征(ACID)

作用:

  • 当数据库操作序列中个别操作失败时,提供一种方式使数据库状态恢复到正常状态(A),保障数据库即使在异常状态下仍能保持数据一致性(C)(要么操作前状态,要么操作后状态)
  • 当出现并发访问数据库时,在多个访问间进行相互隔离,防止并发访问操作结果互相干扰(I

Spring 事务一般加到业务层,对应着业务的操作,Spring 事务的本质其实就是数据库对事务的支持,没有数据库的事务支持,Spring 是无法提供事务功能的,Spring 只提供统一事务管理接口

Spring 在事务开始时,根据当前环境中设置的隔离级别,调整数据库隔离级别,由此保持一致。程序是否支持事务首先取决于数据库 ,比如 MySQL ,如果是 Innodb 引擎,是支持事务的;如果 MySQL 使用 MyISAM 引擎,那从根上就是不支持事务的

保证原子性

  • 要保证事务的原子性,就需要在异常发生时,对已经执行的操作进行回滚
  • 在 MySQL 中,恢复机制是通过回滚日志(undo log) 实现,所有事务进行的修改都会先先记录到这个回滚日志中,然后再执行相关的操作。如果执行过程中遇到异常的话,直接利用回滚日志中的信息将数据回滚到修改之前的样子即可
  • 回滚日志会先于数据持久化到磁盘上,这样保证了即使遇到数据库突然宕机等情况,当用户再次启动数据库的时候,数据库还能够通过查询回滚日志来回滚将之前未完成的事务

隔离级别

TransactionDefinition 接口中定义了五个表示隔离级别的常量:

  • TransactionDefinition.ISOLATION_DEFAULT:使用后端数据库默认的隔离级别,MySQL 默认采用的 REPEATABLE_READ 隔离级别,Oracle 默认采用的 READ_COMMITTED隔离级别.
  • TransactionDefinition.ISOLATION_READ_UNCOMMITTED:最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读
  • TransactionDefinition.ISOLATION_READ_COMMITTED:允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生
  • TransactionDefinition.ISOLATION_REPEATABLE_READ:对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。
  • TransactionDefinition.ISOLATION_SERIALIZABLE:最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。但是这将严重影响程序的性能。通常情况下也不会用到该级别

MySQL InnoDB 存储引擎的默认支持的隔离级别是 REPEATABLE-READ(可重读)

分布式事务:允许多个独立的事务资源(transactional resources)参与到一个全局的事务中。事务资源通常是关系型数据库系统,但也可以是其他类型的资源,全局事务要求在其中的所有参与的事务要么都提交,要么都回滚,这对于事务原有的 ACID 要求又有了提高

在使用分布式事务时,InnoDB 存储引擎的事务隔离级别必须设置为 SERIALIZABLE


传播行为

事务传播行为是为了解决业务层方法之间互相调用的事务问题,也就是方法嵌套:

  • 当事务方法被另一个事务方法调用时,必须指定事务应该如何传播。

  • 例如:方法可能继续在现有事务中运行,也可能开启一个新事务,并在自己的事务中运行

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    //外层事务 Service A 的 aMethod 调用内层 Service B 的 bMethod
    class A {
    @Transactional(propagation=propagation.xxx)
    public void aMethod {
    B b = new B();
    b.bMethod();
    }
    }
    class B {
    @Transactional(propagation=propagation.xxx)
    public void bMethod {}
    }

支持当前事务的情况:

  • TransactionDefinition.PROPAGATION_REQUIRED: 如果当前存在事务则加入该事务;如果当前没有事务则创建一个新的事务
    • 内外层是相同的事务,在 aMethod 或者在 bMethod 内的任何地方出现异常,事务都会被回滚
    • 工作流程:
      • 线程执行到 serviceA.aMethod() 时,其实是执行的代理 serviceA 对象的 aMethod
      • 首先执行事务增强器逻辑(环绕增强),提取事务标签属性,检查当前线程是否绑定 connection 数据库连接资源,没有就调用 datasource.getConnection(),设置事务提交为手动提交 autocommit(false)
      • 执行其他增强器的逻辑,然后调用 target 的目标方法 aMethod() 方法,进入 serviceB 的逻辑
      • serviceB 也是先执行事务增强器的逻辑,提取事务标签属性,但此时会检查到线程绑定了 connection,检查注解的传播属性,所以调用 DataSourceUtils.getConnection(datasource) 共享该连接资源,执行完相关的增强和 SQL 后,发现事务并不是当前方法开启的,可以直接返回上层
      • serviceA.aMethod() 继续执行,执行完增强后进行提交事务或回滚事务
  • TransactionDefinition.PROPAGATION_SUPPORTS: 如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行
  • TransactionDefinition.PROPAGATION_MANDATORY: 如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常

不支持当前事务的情况:

  • TransactionDefinition.PROPAGATION_REQUIRES_NEW: 创建一个新的事务,如果当前存在事务,则把当前事务挂起
    • 内外层是不同的事务,如果 bMethod 已经提交,如果 aMethod 失败回滚 ,bMethod 不会回滚
    • 如果 bMethod 失败回滚,ServiceB 抛出的异常被 ServiceA 捕获,如果 B 抛出的异常是 A 会回滚的异常,aMethod 事务需要回滚,否则仍然可以提交
  • TransactionDefinition.PROPAGATION_NOT_SUPPORTED: 以非事务方式运行,如果当前存在事务,则把当前事务挂起
  • TransactionDefinition.PROPAGATION_NEVER: 以非事务方式运行,如果当前存在事务,则抛出异常

其他情况:

  • TransactionDefinition.PROPAGATION_NESTED: 如果当前存在事务,则创建一个事务作为当前事务的嵌套事务(两个事务没有关系)来运行
    • 如果 ServiceB 异常回滚,可以通过 try-catch 机制执行 ServiceC
    • 如果 ServiceB 提交, ServiceA 可以根据具体的配置决定是 commit 还是 rollback
    • 应用场景:在查询数据的时候要向数据库中存储一些日志,系统不希望存日志的行为影响到主逻辑,可以使用该传播

requied:必须的、supports:支持的、mandatory:强制的、nested:嵌套的


超时属性

事务超时,指一个事务所允许执行的最长时间,如果超过该时间限制事务还没有完成,则自动回滚事务。在 TransactionDefinition 中以 int 的值来表示超时时间,其单位是秒,默认值为 -1


只读属性

对于只有读取数据查询的事务,可以指定事务类型为 readonly,即只读事务;只读事务不涉及数据的修改,数据库会提供一些优化手段,适合用在有多条数据库查询操作的方法中

读操作为什么需要启用事务支持:

  • MySQL 默认对每一个新建立的连接都启用了 autocommit 模式,在该模式下,每一个发送到 MySQL 服务器的 SQL 语句都会在一个单独的事务中进行处理,执行结束后会自动提交事务,并开启一个新的事务
  • 执行多条查询语句,如果方法加上了 @Transactional 注解,这个方法执行的所有 SQL 会被放在一个事务中,如果声明了只读事务的话,数据库就会去优化它的执行,并不会带来其他的收益。如果不加 @Transactional,每条 SQL 会开启一个单独的事务,中间被其它事务修改了数据,比如在前条 SQL 查询之后,后条 SQL 查询之前,数据被其他用户改变,则这次整体的统计查询将会出现读数据不一致的状态

核心对象

事务对象

J2EE 开发使用分层设计的思想进行,对于简单的业务层转调数据层的单一操作,事务开启在业务层或者数据层并无太大差别,当业务中包含多个数据层的调用时,需要在业务层开启事务,对数据层中多个操作进行组合并归属于同一个事务进行处理

Spring 为业务层提供了整套的事务解决方案:

  • PlatformTransactionManager

  • TransactionDefinition

  • TransactionStatus


PTM

PlatformTransactionManager,平台事务管理器实现类:

  • DataSourceTransactionManager 适用于 Spring JDBC 或 MyBatis

  • HibernateTransactionManager 适用于 Hibernate3.0 及以上版本

  • JpaTransactionManager 适用于 JPA

  • JdoTransactionManager 适用于 JDO

  • JtaTransactionManager 适用于 JTA

管理器:

  • JPA(Java Persistence API)Java EE 标准之一,为 POJO 提供持久化标准规范,并规范了持久化开发的统一 API,符合 JPA 规范的开发可以在不同的 JPA 框架下运行

    非持久化一个字段

    1
    2
    3
    4
    5
    static String transient1; // not persistent because of static
    final String transient2 = “Satish”; // not persistent because of final
    transient String transient3; // not persistent because of transient
    @Transient
    String transient4; // not persistent because of @Transient
  • JDO(Java Data Object)是 Java 对象持久化规范,用于存取某种数据库中的对象,并提供标准化 API。JDBC 仅针对关系数据库进行操作,JDO 可以扩展到关系数据库、XML、对象数据库等,可移植性更强

  • JTA(Java Transaction API)Java EE 标准之一,允许应用程序执行分布式事务处理。与 JDBC 相比,JDBC 事务则被限定在一个单一的数据库连接,而一个 JTA 事务可以有多个参与者,比如 JDBC 连接、JDO 都可以参与到一个 JTA 事务中

此接口定义了事务的基本操作:

方法 说明
TransactionStatus getTransaction(TransactionDefinition definition) 获取事务
void commit(TransactionStatus status) 提交事务
void rollback(TransactionStatus status) 回滚事务

Definition

TransactionDefinition 此接口定义了事务的基本信息:

方法 说明
String getName() 获取事务定义名称
boolean isReadOnly() 获取事务的读写属性
int getIsolationLevel() 获取事务隔离级别
int getTimeout() 获取事务超时时间
int getPropagationBehavior() 获取事务传播行为特征

Status

TransactionStatus 此接口定义了事务在执行过程中某个时间点上的状态信息及对应的状态操作:

方法 说明
boolean isNewTransaction() 获取事务是否处于新开始事务状态
voin flush() 刷新事务状态
boolean isCompleted() 获取事务是否处于已完成状态
boolean hasSavepoint() 获取事务是否具有回滚储存点
boolean isRollbackOnly() 获取事务是否处于回滚状态
void setRollbackOnly() 设置事务处于回滚状态

编程式

控制方式

编程式、声明式(XML)、声明式(注解)

环境准备

银行转账业务

  • 包装类

    1
    2
    3
    4
    5
    6
    public class Account implements Serializable {
    private Integer id;
    private String name;
    private Double money;
    .....
    }
  • DAO层接口:AccountDao

    1
    2
    3
    4
    5
    6
    7
    public interface AccountDao {
    //入账操作 name:入账用户名 money:入账金额
    void inMoney(@Param("name") String name, @Param("money") Double money);

    //出账操作 name:出账用户名 money:出账金额
    void outMoney(@Param("name") String name, @Param("money") Double money);
    }
  • 业务层接口提供转账操作:AccountService

    1
    2
    3
    4
    public interface AccountService {
    //转账操作 outName:出账用户名 inName:入账用户名 money:转账金额
    public void transfer(String outName,String inName,Double money);
    }
  • 业务层实现提供转账操作:AccountServiceImpl

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class AccountServiceImpl implements AccountService {
    private AccountDao accountDao;
    public void setAccountDao(AccountDao accountDao) {
    this.accountDao = accountDao;
    }
    @Override
    public void transfer(String outName,String inName,Double money){
    accountDao.inMoney(outName,money);
    accountDao.outMoney(inName,money);
    }
    }
  • 映射配置文件:dao / AccountDao.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <mapper namespace="dao.AccountDao">
    <update id="inMoney">
    UPDATE account SET money = money + #{money} WHERE name = #{name}
    </update>

    <update id="outMoney">
    UPDATE account SET money = money - #{money} WHERE name = #{name}
    </update>
    </mapper>
  • jdbc.properties

    1
    2
    3
    4
    jdbc.driver=com.mysql.jdbc.Driver
    jdbc.url=jdbc:mysql://192.168.2.185:3306/spring_db
    jdbc.username=root
    jdbc.password=1234
  • 核心配置文件:applicationContext.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    <context:property-placeholder location="classpath:*.properties"/>

    <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource">
    <property name="driverClassName" value="${jdbc.driver}"/>
    <property name="url" value="${jdbc.url}"/>
    <property name="username" value="${jdbc.username}"/>
    <property name="password" value="${jdbc.password}"/>
    </bean>

    <bean id="accountService" class="service.impl.AccountServiceImpl">
    <property name="accountDao" ref="accountDao"/>
    </bean>

    <bean class="org.mybatis.spring.SqlSessionFactoryBean">
    <property name="dataSource" ref="dataSource"/>
    <property name="typeAliasesPackage" value="domain"/>
    </bean>
    <!--扫描映射配置和Dao-->
    <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
    <property name="basePackage" value="dao"/>
    </bean>
  • 测试类

    1
    2
    3
    ApplicationContext ctx = new ClassPathXmlApplicationContext("ap...xml");
    AccountService accountService = (AccountService) ctx.getBean("accountService");
    accountService.transfer("Jock1", "Jock2", 100d);

编程式

编程式事务就是代码显式的给出事务的开启和提交

  • 修改业务层实现提供转账操作:AccountServiceImpl

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public void transfer(String outName,String inName,Double money){
    //1.创建事务管理器,
    DataSourceTransactionManager dstm = new DataSourceTransactionManager();
    //2.为事务管理器设置与数据层相同的数据源
    dstm.setDataSource(dataSource);
    //3.创建事务定义对象
    TransactionDefinition td = new DefaultTransactionDefinition();
    //4.创建事务状态对象,用于控制事务执行,【开启事务】
    TransactionStatus ts = dstm.getTransaction(td);
    accountDao.inMoney(inName,money);
    int i = 1/0; //模拟业务层事务过程中出现错误
    accountDao.outMoney(outName,money);
    //5.提交事务
    dstm.commit(ts);
    }
  • 配置 applicationContext.xml

    1
    2
    3
    4
    5
    <!--添加属性注入-->
    <bean id="accountService" class="service.impl.AccountServiceImpl">
    <property name="accountDao" ref="accountDao"/>
    <property name="dataSource" ref="dataSource"/>
    </bean>

AOP改造

  • 将业务层的事务处理功能抽取出来制作成 AOP 通知,利用环绕通知运行期动态织入

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    public class TxAdvice {
    private DataSource dataSource;
    public void setDataSource(DataSource dataSource) {
    this.dataSource = dataSource;
    }

    public Object tx(ProceedingJoinPoint pjp) throws Throwable {
    //开启事务
    PlatformTransactionManager ptm = new DataSourceTransactionManager(dataSource);
    //事务定义
    TransactionDefinition td = new DefaultTransactionDefinition();
    //事务状态
    TransactionStatus ts = ptm.getTransaction(td);
    //pjp.getArgs()标准写法,也可以不加,同样可以传递参数
    Object ret = pjp.proceed(pjp.getArgs());

    //提交事务
    ptm.commit(ts);

    return ret;
    }
    }
  • 配置 applicationContext.xml,要开启 AOP 空间

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    <!--修改bean的属性注入-->
    <bean id="accountService" class="service.impl.AccountServiceImpl">
    <property name="accountDao" ref="accountDao"/>
    </bean>

    <!--配置AOP通知类,并注入dataSource-->
    <bean id="txAdvice" class="aop.TxAdvice">
    <property name="dataSource" ref="dataSource"/>
    </bean>

    <!--使用环绕通知将通知类织入到原始业务对象执行过程中-->
    <aop:config>
    <aop:pointcut id="pt" expression="execution(* *..transfer(..))"/>
    <aop:aspect ref="txAdvice">
    <aop:around method="tx" pointcut-ref="pt"/>
    </aop:aspect>
    </aop:config>
  • 修改业务层实现提供转账操作:AccountServiceImpl

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public class AccountServiceImpl implements AccountService {
    private AccountDao accountDao;
    public void setAccountDao(AccountDao accountDao) {
    this.accountDao = accountDao;
    }
    @Override
    public void transfer(String outName,String inName,Double money){
    accountDao.inMoney(outName,money);
    //int i = 1 / 0;
    accountDao.outMoney(inName,money);
    }
    }

声明式

XML

tx使用

删除 TxAdvice 通知类,开启 tx 命名空间,配置 applicationContext.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!--配置平台事务管理器-->
<bean id="txManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="dataSource"/>
</bean>

<!--定义事务管理的通知类-->
<tx:advice id="txAdvice" transaction-manager="txManager">
<!--定义控制的事务-->
<tx:attributes>
<tx:method name="transfer" read-only="false"/>
</tx:attributes>
</tx:advice>

<!--使用aop:advisor在AOP配置中引用事务专属通知类,底层invoke调用-->
<aop:config>
<aop:pointcut id="pt" expression="execution(* service.*Service.*(..))"/>
<aop:advisor advice-ref="txAdvice" pointcut-ref="pt"/>
</aop:config>
  • aop:advice 与 aop:advisor 区别
    • aop:advice 配置的通知类可以是普通 Java 对象,不实现接口,也不使用继承关系

    • aop:advisor 配置的通知类必须实现通知接口,底层 invoke 调用

      • MethodBeforeAdvice

      • AfterReturningAdvice

      • ThrowsAdvice

pom.xml 文件引入依赖:

1
2
3
4
5
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
<version>5.1.9.RELEASE</version>
</dependency>

tx配置
advice

标签:tx:advice,beans 的子标签

作用:专用于声明事务通知

格式:

1
2
3
4
<beans>
<tx:advice id="txAdvice" transaction-manager="txManager">
</tx:advice>
</beans>

基本属性:

  • id:用于配置 aop 时指定通知器的 id
  • transaction-manager:指定事务管理器 bean
attributes

类型:tx:attributes,tx:advice 的子标签

作用:定义通知属性

格式:

1
2
3
4
<tx:advice id="txAdvice" transaction-manager="txManager">
<tx:attributes>
</tx:attributes>
</tx:advice>
method

标签:tx:method,tx:attribute 的子标签

作用:设置具体的事务属性

格式:

1
2
3
4
5
6
7
<tx:attributes>
<!--标准格式-->
<tx:method name="*" read-only="false"/>
<tx:method name="get*" read-only="true"/>
<tx:method name="find*" read-only="true"/>
</tx:attributes>
<aop:pointcut id="pt" expression="execution(* service.*Service.*(..))"/><!--标准-->

说明:通常事务属性会配置多个,包含 1 个读写的全事务属性,1 个只读的查询类事务属性

属性:

  • name:待添加事务的方法名表达式(支持 * 通配符)
  • read-only:设置事务的读写属性,true 为只读,false 为读写
  • timeout:设置事务的超时时长,单位秒,-1 为无限长
  • isolation:设置事务的隔离界别,该隔离级设定是基于 Spring 的设定,非数据库端
  • no-rollback-for:设置事务中不回滚的异常,多个异常使用 , 分隔
  • rollback-for:设置事务中必回滚的异常,多个异常使用 , 分隔
  • propagation:设置事务的传播行为

注解

开启注解
XML

标签:tx:annotation-driven

归属:beans 标签

作用:开启事务注解驱动,并指定对应的事务管理器

范例:

1
<tx:annotation-driven transaction-manager="txManager"/>

纯注解

名称:@EnableTransactionManagement

类型:类注解,Spring 注解配置类上方

作用:开启注解驱动,等同 XML 格式中的注解驱动

范例:

1
2
3
4
5
6
7
@Configuration
@ComponentScan("com.seazean")
@PropertySource("classpath:jdbc.properties")
@Import({JDBCConfig.class,MyBatisConfig.class,TransactionManagerConfig.class})
@EnableTransactionManagement
public class SpringConfig {
}
1
2
3
4
5
6
public class TransactionManagerConfig {
@Bean //自动装配
public PlatformTransactionManager getTransactionManager(@Autowired DataSource dataSource){
return new DataSourceTransactionManager(dataSource);
}
}

配置注解

名称:@Transactional

类型:方法注解,类注解,接口注解

作用:设置当前类/接口中所有方法或具体方法开启事务,并指定相关事务属性

范例:

1
2
3
4
5
6
7
8
9
@Transactional(
readOnly = false,
timeout = -1,
isolation = Isolation.DEFAULT,
rollbackFor = {ArithmeticException.class, IOException.class},
noRollbackFor = {},
propagation = Propagation.REQUIRES_NEW
)
public void addAccount{}

说明:

  • @Transactional 注解只有作用到 public 方法上事务才生效

  • 不推荐在接口上使用 @Transactional 注解

    原因:在接口上使用注解,只有在使用基于接口的代理(JDK)时才会生效,因为注解是不能继承的,这就意味着如果正在使用基于类的代理(CGLIB)时,那么事务的设置将不能被基于类的代理所识别

  • 正确的设置 @Transactional 的 rollbackFor 和 propagation 属性,否则事务可能会回滚失败

  • 默认情况下,事务只有遇到运行期异常 和 Error 会导致事务回滚,但是在遇到检查型(Checked)异常时不会回滚

    • 继承自 RuntimeException 或 error 的是非检查型异常,比如空指针和索引越界,而继承自 Exception 的则是检查型异常,比如 IOException、ClassNotFoundException,RuntimeException 本身继承 Exception
    • 非检查型类异常可以不用捕获,而检查型异常则必须用 try 语句块把异常交给上级方法,这样事务才能有效

事务不生效的问题

  • 情况 1:确认创建的 MySQL 数据库表引擎是 InnoDB,MyISAM 不支持事务

  • 情况 2:注解到 protected,private 方法上事务不生效,但不会报错

    原因:理论上而言,不用 public 修饰,也可以用 aop 实现事务的功能,但是方法私有化让其他业务无法调用

    AopUtils.canApply:methodMatcher.matches(method, targetClass) --true--> return true
    TransactionAttributeSourcePointcut.matches() ,AbstractFallbackTransactionAttributeSource 中 getTransactionAttribute 方法调用了其本身的 computeTransactionAttribute 方法,当加了事务注解的方法不是 public 时,该方法直接返回 null,所以造成增强不匹配

    1
    2
    3
    4
    5
    6
    private TransactionAttribute computeTransactionAttribute(Method method, Class<?> targetClass) {
    // Don't allow no-public methods as required.
    if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) {
    return null;
    }
    }
  • 情况 3:注解所在的类没有被加载成 Bean

  • 情况 4:在业务层捕捉异常后未向上抛出,事务不生效

    原因:在业务层捕捉并处理了异常(try..catch)等于把异常处理掉了,Spring 就不知道这里有错,也不会主动去回滚数据,推荐做法是在业务层统一抛出异常,然后在控制层统一处理

  • 情况 5:遇到检测异常时,也无法回滚

    原因:Spring 的默认的事务规则是遇到运行异常(RuntimeException)和程序错误(Error)才会回滚。想针对检测异常进行事务回滚,可以在 @Transactional 注解里使用 rollbackFor 属性明确指定异常

  • 情况 6:Spring 的事务传播策略在内部方法调用时将不起作用,在一个 Service 内部,事务方法之间的嵌套调用,普通方法和事务方法之间的嵌套调用,都不会开启新的事务,事务注解要加到调用方法上才生效

    原因:Spring 的事务都是使用 AOP 代理的模式,动态代理 invoke 后会调用原始对象,而原始对象在去调用方法时是不会触发拦截器,就是一个方法调用本对象的另一个方法,所以事务也就无法生效

    1
    2
    3
    4
    5
    6
    @Transactional
    public int add(){
    update();
    }
    //注解添加在update方法上无效,需要添加到add()方法上
    public int update(){}
  • 情况 7:注解在接口上,代理对象是 CGLIB


使用注解
  • Dao 层

    1
    2
    3
    4
    5
    6
    7
    public interface AccountDao {
    @Update("update account set money = money + #{money} where name = #{name}")
    void inMoney(@Param("name") String name, @Param("money") Double money);

    @Update("update account set money = money - #{money} where name = #{name}")
    void outMoney(@Param("name") String name, @Param("money") Double money);
    }
  • 业务层

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public interface AccountService {
    //对当前方法添加事务,该配置将替换接口的配置
    @Transactional(
    readOnly = false,
    timeout = -1,
    isolation = Isolation.DEFAULT,
    rollbackFor = {},//java.lang.ArithmeticException.class, IOException.class
    noRollbackFor = {},
    propagation = Propagation.REQUIRED
    )
    public void transfer(String outName, String inName, Double money);
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    public class AccountServiceImpl implements AccountService {
    @Autowired
    private AccountDao accountDao;
    public void transfer(String outName, String inName, Double money) {
    accountDao.inMoney(outName,money);
    //int i = 1/0;
    accountDao.outMoney(inName,money);
    }
    }
  • 添加文件 Spring.config、Mybatis.config、JDBCConfig (参考ioc_Mybatis)、TransactionManagerConfig

    1
    2
    3
    4
    5
    6
    7
    @Configuration
    @ComponentScan({"","",""})
    @PropertySource("classpath:jdbc.properties")
    @Import({JDBCConfig.class,MyBatisConfig.class})
    @EnableTransactionManagement
    public class SpringConfig {
    }

模板对象

Spring 模板对象:TransactionTemplate、JdbcTemplate、RedisTemplate、RabbitTemplate、JmsTemplate、HibernateTemplate、RestTemplate

  • JdbcTemplate:提供标准的 sql 语句操作API

  • NamedParameterJdbcTemplate:提供标准的具名 sql 语句操作API

  • RedisTemplate:

    1
    2
    3
    4
    5
    6
    7
    public void changeMoney(Integer id, Double money) {
    redisTemplate.opsForValue().set("account:id:"+id,money);
    }
    public Double findMondyById(Integer id) {
    Object money = redisTemplate.opsForValue().get("account:id:" + id);
    return new Double(money.toString());
    }


原理

XML

三大对象:

  • BeanDefinition:是 Spring 中极其重要的一个概念,存储了 bean 对象的所有特征信息,如是否单例、是否懒加载、factoryBeanName 等,和 bean 的关系就是类与对象的关系,一个不同的 bean 对应一个 BeanDefinition

  • BeanDefinationRegistry:存放 BeanDefination 的容器,是一种键值对的形式,通过特定的 Bean 定义的 id,映射到相应的 BeanDefination,BeanFactory 的实现类同样继承 BeanDefinationRegistry 接口,拥有保存 BD 的能力

  • BeanDefinitionReader:读取配置文件,XML 用 Dom4j 解析注解用 IO 流加载解析

程序:

1
2
BeanFactory bf = new XmlBeanFactory(new ClassPathResource("applicationContext.xml"));
UserService userService1 = (UserService)bf.getBean("userService");

源码解析:

1
2
3
4
5
6
7
8
9
public XmlBeanFactory(Resource resource, BeanFactory parentBeanFactory) {
super(parentBeanFactory);
this.reader.loadBeanDefinitions(resource);
}
public int loadBeanDefinitions(Resource resource) {
//将 resource 包装成带编码格式的 EncodedResource
//EncodedResource 中 getReader()方法,调用java.io包下的 转换流 创建指定编码的输入流对象
return loadBeanDefinitions(new EncodedResource(resource));
}
  • XmlBeanDefinitionReader.loadBeanDefinitions()把 Resource 解析成 BeanDefinition 对象

    • currentResources = this.resourcesCurrentlyBeingLoaded.get():拿到当前线程已经加载过的所有 EncodedResoure 资源,用 ThreadLocal 保证线程安全
    • if (currentResources == null):判断 currentResources 是否为空,为空则进行初始化
    • if (!currentResources.add(encodedResource)):如果已经加载过该资源会报错,防止重复加载
    • inputSource = new InputSource(inputStream):资源对象包装成 InputSource,InputSource 是 SAX 中的资源对象,用来进行 XML 文件的解析
    • return doLoadBeanDefinitions()加载返回
    • currentResources.remove(encodedResource):加载完成移除当前 encodedResource
    • resourcesCurrentlyBeingLoaded.remove():ThreadLocal 为空时移除元素,防止内存泄露
  • XmlBeanDefinitionReader.doLoadBeanDefinitions(inputSource, resource):真正的加载函数

    Document doc = doLoadDocument(inputSource, resource):转换成有层次结构的 Document 对象

    • getEntityResolver():获取用来解析 DTD、XSD 约束的解析器

    • getValidationModeForResource(resource):获取验证模式

    int count = registerBeanDefinitions(doc, resource)将 Document 解析成 BD 对象,注册(添加)到 BeanDefinationRegistry 中,返回新注册的数量

    • createBeanDefinitionDocumentReader():创建 DefaultBeanDefinitionDocumentReader 对象
    • getRegistry().getBeanDefinitionCount():获取解析前 BeanDefinationRegistry 中的 bd 数量
    • registerBeanDefinitions(doc, readerContext):注册 BD
      • this.readerContext = readerContext:保存上下文对象
      • doRegisterBeanDefinitions(doc.getDocumentElement()):真正的注册 BD 函数
        • doc.getDocumentElement():拿出顶层标签
    • return getRegistry().getBeanDefinitionCount() - countBefore:返回新加入的数量
  • DefaultBeanDefinitionDocumentReader.doRegisterBeanDefinitions():注册 BD 到 BR

    • createDelegate(getReaderContext(), root, parent):beans 是标签的解析器对象
    • delegate.isDefaultNamespace(root):判断 beans 标签是否是默认的属性
    • root.getAttribute(PROFILE_ATTRIBUTE):解析 profile 属性
    • preProcessXml(root):解析前置处理,自定义实现
    • parseBeanDefinitions(root, this.delegate)解析 beans 标签中的子标签
      • parseDefaultElement(ele, delegate):如果是默认的标签,用该方法解析子标签
        • 判断标签名称,进行相应的解析
        • processBeanDefinition(ele, delegate)
      • delegate.parseCustomElement(ele):解析自定义的标签
    • postProcessXml(root):解析后置处理
  • DefaultBeanDefinitionDocumentReader.processBeanDefinition()解析 bean 标签并注册到注册中心

    • delegate.parseBeanDefinitionElement(ele):解析 bean 标签封装为 BeanDefinitionHolder

      • if (!StringUtils.hasText(beanName) && !aliases.isEmpty()):条件一成立说明 name 没有值,条件二成立说明别名有值

        beanName = aliases.remove(0):拿别名列表的第一个元素当作 beanName

      • parseBeanDefinitionElement(ele, beanName, containingBean)解析 bean 标签

        • parseState.push(new BeanEntry(beanName)):当前解析器的状态设置为 BeanEntry
        • class 和 parent 属性存在一个,parent 是作为父标签为了被继承
        • createBeanDefinition(className, parent):设置了class 的 GenericBeanDefinition对象
        • parseBeanDefinitionAttributes():解析 bean 标签的属性
        • 接下来解析子标签
      • beanName = this.readerContext.generateBeanName(beanDefinition):生成 className + # + 序号的名称赋值给 beanName

      • return new BeanDefinitionHolder(beanDefinition, beanName, aliases)包装成 BeanDefinitionHolder

    • registerBeanDefinition(bdHolder, getReaderContext().getRegistry())注册到容器

      • beanName = definitionHolder.getBeanName():获取beanName
      • this.beanDefinitionMap.put(beanName, beanDefinition):添加到注册中心
    • getReaderContext().fireComponentRegistered():发送注册完成事件

说明:源码部分的笔记不一定适合所有人阅读,作者采用流水线式去解析重要的代码,解析的结构类似于树状,如果视觉疲劳可以去网上参考一些博客和流程图学习源码。


IOC

容器启动

Spring IOC 容器是 ApplicationContext 或者 BeanFactory,使用多个 Map 集合保存单实例 Bean,环境信息等资源,不同层级有不同的容器,比如整合 SpringMVC 的父子容器(先看 Bean 部分的源码解析再回看容器)

ClassPathXmlApplicationContext 与 AnnotationConfigApplicationContext 差不多:

1
2
3
4
5
public AnnotationConfigApplicationContext(Class<?>... annotatedClasses) {
this();
register(annotatedClasses);// 解析配置类,封装成一个 BeanDefinitionHolder,并注册到容器
refresh();// 加载刷新容器中的 Bean
}
1
2
3
4
5
6
public AnnotationConfigApplicationContext() {
// 注册 Spring 的注解解析器到容器
this.reader = new AnnotatedBeanDefinitionReader(this);
// 实例化路径扫描器,用于对指定的包目录进行扫描查找 bean 对象
this.scanner = new ClassPathBeanDefinitionScanner(this);
}

AbstractApplicationContext.refresh():

  • prepareRefresh():刷新前的预处理

    • this.startupDate = System.currentTimeMillis():设置容器的启动时间
    • initPropertySources():初始化一些属性设置,可以自定义个性化的属性设置方法
    • getEnvironment().validateRequiredProperties():检查环境变量
    • earlyApplicationEvents= new LinkedHashSet<ApplicationEvent>():保存容器中早期的事件
  • obtainFreshBeanFactory():获取一个全新的 BeanFactory 接口实例,如果容器中存在工厂实例直接销毁

    refreshBeanFactory():创建 BeanFactory,设置序列化 ID、读取 BeanDefinition 并加载到工厂

    • if (hasBeanFactory()):applicationContext 内部拥有一个 beanFactory 实例,需要将该实例完全释放销毁
    • destroyBeans():销毁原 beanFactory 实例,将 beanFactory 内部维护的单实例 bean 全部清掉,如果哪个 bean 实现了 Disposablejie接口,还会进行 bean distroy 方法的调用处理
      • this.singletonsCurrentlyInDestruction = true:设置当前 beanFactory 状态为销毁状态
      • String[] disposableBeanNames:获取销毁集合中的 bean,如果当前 bean 有析构函数就会在销毁集合
      • destroySingleton(disposableBeanNames[i]):遍历所有的 disposableBeans,执行销毁方法
        • removeSingleton(beanName):清除三级缓存和 registeredSingletons 中的当前 beanName 的数据
        • this.disposableBeans.remove(beanName):从销毁集合中清除,每个 bean 只能 destroy 一次
        • destroyBean(beanName, disposableBean):销毁 bean
          • dependentBeanMap 记录了依赖当前 bean 的其他 bean 信息,因为依赖的对象要被回收了,所以依赖当前 bean 的其他对象都要执行 destroySingleton,遍历 dependentBeanMap 执行销毁
          • bean.destroy():解决完成依赖后,执行 DisposableBean 的 destroy 方法
          • this.dependenciesForBeanMap.remove(beanName):保存当前 bean 依赖了谁,直接清除
      • 进行一些集合和缓存的清理工作
    • closeBeanFactory():将容器内部的 beanFactory 设置为空,重新创建
    • beanFactory = createBeanFactory():创建新的 DefaultListableBeanFactory 对象
    • beanFactory.setSerializationId(getId()):进行 ID 的设置,可以根据 ID 获取 BeanFactory 对象
    • customizeBeanFactory(beanFactory):设置是否允许覆盖和循环引用
    • loadBeanDefinitions(beanFactory)加载 BeanDefinition 信息,注册 BD注册到 BeanFactory 中
    • this.beanFactory = beanFactory:把 beanFactory 填充至容器中

    getBeanFactory():返回创建的 DefaultListableBeanFactory 对象,该对象继承 BeanDefinitionRegistry

  • prepareBeanFactory(beanFactory):BeanFactory 的预准备工作,向容器中添加一些组件

    • setBeanClassLoader(getClassLoader()):给当前 bf 设置一个类加载器,加载 bd 的 class 信息
    • setBeanExpressionResolver():设置 EL 表达式解析器
    • addPropertyEditorRegistrar:添加一个属性编辑器,解决属性注入时的格式转换
    • addBeanPostProcessor():添加后处理器,主要用于向 bean 内部注入一些框架级别的实例
    • ignoreDependencyInterface():设置忽略自动装配的接口,bean 内部的这些类型的字段 不参与依赖注入
    • registerResolvableDependency():注册一些类型依赖关系
    • addBeanPostProcessor():将配置的监听者注册到容器中,当前 bean 实现 ApplicationListener 接口就是监听器事件
    • beanFactory.registerSingleton():添加一些系统信息
  • postProcessBeanFactory(beanFactory):BeanFactory 准备工作完成后进行的后置处理工作,扩展方法

  • invokeBeanFactoryPostProcessors(beanFactory):执行 BeanFactoryPostProcessor 的方法

    • processedBeans = new HashSet<>():存储已经执行过的 BeanFactoryPostProcessor 的 beanName

    • if (beanFactory instanceof BeanDefinitionRegistry)当前 BeanFactory 是 bd 的注册中心,bd 全部注册到 bf

    • for (BeanFactoryPostProcessor postProcessor : beanFactoryPostProcessors):遍历所有的 bf 后置处理器

    • if (postProcessor instanceof BeanDefinitionRegistryPostProcessor):是 Registry 类的后置处理器

      registryProcessor.postProcessBeanDefinitionRegistry(registry):向 bf 中注册一些 bd

      registryProcessors.add(registryProcessor):添加到 BeanDefinitionRegistryPostProcessor 集合

    • regularPostProcessors.add(postProcessor):添加到 BeanFactoryPostProcessor 集合

    • 逻辑到这里已经获取到所有 BeanDefinitionRegistryPostProcessor 和 BeanFactoryPostProcessor 接口类型的后置处理器

    • 首先回调 BeanDefinitionRegistryPostProcessor 类的后置处理方法 postProcessBeanDefinitionRegistry()

      • 获取实现了 PriorityOrdered(主排序接口)接口的 bdrpp,进行 sort 排序,然后全部执行并放入已经处理过的集合

      • 再执行实现了 Ordered(次排序接口)接口的 bdrpp

      • 最后执行没有实现任何优先级或者是顺序接口 bdrpp,boolean reiterate = true 控制 while 是否需要再次循环,循环内是查找并执行 bdrpp 后处理器的 registry 相关的接口方法,接口方法执行以后会向 bf 内注册 bd,注册的 bd 也有可能是 bdrpp 类型,所以需要该变量控制循环

      • processedBeans.add(ppName):已经执行过的后置处理器存储到该集合中,防止重复执行

      • invokeBeanFactoryPostProcessors():bdrpp 继承了 BeanFactoryPostProcessor,有 postProcessBeanFactory 方法

    • 执行普通 BeanFactoryPostProcessor 的相关 postProcessBeanFactory 方法,按照主次无次序执行

      • if (processedBeans.contains(ppName)):会过滤掉已经执行过的后置处理器
    • beanFactory.clearMetadataCache():清除缓存中合并的 Bean 定义,因为后置处理器可能更改了元数据

以上是 BeanFactory 的创建及预准备工作,接下来进入 Bean 的流程

  • registerBeanPostProcessors(beanFactory):注册 Bean 的后置处理器,为了干预 Spring 初始化 bean 的流程,这里仅仅是向容器中注入而非使用

    • beanFactory.getBeanNamesForType(BeanPostProcessor.class)获取配置中实现了 BeanPostProcessor 接口类型

    • int beanProcessorTargetCount:后置处理器的数量,已经注册的 + 未注册的 + 即将要添加的一个

    • beanFactory.addBeanPostProcessor(new BeanPostProcessorChecker()):添加一个检查器

      BeanPostProcessorChecker.postProcessAfterInitialization():初始化后的后处理器方法

      • !(bean instanceof BeanPostProcessor) :当前 bean 类型是普通 bean,不是后置处理器
      • !isInfrastructureBean(beanName):成立说明当前 beanName 是用户级别的 bean 不是 Spring 框架的
      • this.beanFactory.getBeanPostProcessorCount() < this.beanPostProcessorTargetCount:BeanFactory 上面注册后处理器数量 < 后处理器数量,说明后处理框架尚未初始化完成
    • for (String ppName : postProcessorNames):遍历 PostProcessor 集合,根据实现不同的顺序接口添加到不同集合

    • sortPostProcessors(priorityOrderedPostProcessors, beanFactory):实现 PriorityOrdered 接口的后处理器排序

      registerBeanPostProcessors(beanFactory, priorityOrderedPostProcessors)注册到 beanFactory 中

    • 接着排序注册实现 Ordered 接口的后置处理器,然后注册普通的( 没有实现任何优先级接口)后置处理器

    • 最后排序 MergedBeanDefinitionPostProcessor 类型的处理器,根据实现的排序接口,排序完注册到 beanFactory 中

    • beanFactory.addBeanPostProcessor(new ApplicationListenerDetector(applicationContext)):重新注册 ApplicationListenerDetector 后处理器,用于在 Bean 创建完成后检查是否属于 ApplicationListener 类型,如果是就把 Bean 放到监听器容器中保存起来

  • initMessageSource():初始化 MessageSource 组件,主要用于做国际化功能,消息绑定与消息解析

    • if (beanFactory.containsLocalBean(MESSAGE_SOURCE_BEAN_NAME)):容器是否含有名称为 messageSource 的 bean
    • beanFactory.getBean(MESSAGE_SOURCE_BEAN_NAME, MessageSource.class):如果有证明用户自定义了该类型的 bean,获取后直接赋值给 this.messageSource
    • dms = new DelegatingMessageSource():容器中没有就新建一个赋值
  • initApplicationEventMulticaster():初始化事件传播器,在注册监听器时会用到

    • if (beanFactory.containsLocalBean(APPLICATION_EVENT_MULTICASTER_BEAN_NAME))条件成立说明用户自定义了事件传播器,可以实现 ApplicationEventMulticaster 接口编写自己的事件传播器,通过 bean 的方式提供给 Spring
    • 如果有就直接从容器中获取;如果没有则创建一个 SimpleApplicationEventMulticaster 注册
  • onRefresh():留给用户去实现,可以硬编码提供一些组件,比如提供一些监听器

  • registerListeners():注册通过配置提供的 Listener,这些监听器最终注册到 ApplicationEventMulticaster 内

    • for (ApplicationListener<?> listener : getApplicationListeners()) :注册编码实现的监听器

    • getBeanNamesForType(ApplicationListener.class, true, false):注册通过配置提供的 Listener

    • multicastEvent(earlyEvent)发布前面步骤产生的事件 applicationEvents

      Executor executor = getTaskExecutor():获取线程池,有线程池就异步执行,没有就同步执行

  • finishBeanFactoryInitialization():实例化非懒加载状态的单实例

    • beanFactory.freezeConfiguration()冻结配置信息,就是冻结 BD 信息,冻结后无法再向 bf 内注册 bd

    • beanFactory.preInstantiateSingletons():实例化 non-lazy-init singletons

      • for (String beanName : beanNames):遍历容器内所有的 beanDefinitionNames

      • getMergedLocalBeanDefinition(beanName):获取与父类合并后的对象(Bean → 获取流程部分详解此函数)

      • if (!bd.isAbstract() && bd.isSingleton() && !bd.isLazyInit()):BD 对应的 Class 满足非抽象、单实例,非懒加载,需要预先实例化

        if (isFactoryBean(beanName)):BD 对应的 Class 是 factoryBean 对象

        • getBean(FACTORY_BEAN_PREFIX + beanName):获取工厂 FactoryBean 实例本身
        • isEagerInit:控制 FactoryBean 内部管理的 Bean 是否也初始化
        • getBean(beanName)初始化 Bean,获取 Bean 详解此函数

        getBean(beanName):不是工厂 bean 直接获取

      • for (String beanName : beanNames):检查所有的 Bean 是否实现 SmartInitializingSingleton 接口,实现了就执行 afterSingletonsInstantiated(),进行一些创建后的操作

  • finishRefresh():完成刷新后做的一些事情,主要是启动生命周期

    • clearResourceCaches():清空上下文缓存
    • initLifecycleProcessor()初始化和生命周期有关的后置处理器,容器的生命周期
      • if (beanFactory.containsLocalBean(LIFECYCLE_PROCESSOR_BEAN_NAME)):成立说明自定义了生命周期处理器
      • defaultProcessor = new DefaultLifecycleProcessor():Spring 默认提供的生命周期处理器
      • beanFactory.registerSingleton():将生命周期处理器注册到 bf 的一级缓存和注册单例集合中
    • getLifecycleProcessor().onRefresh():获取该**生命周期后置处理器回调 onRefresh()**,调用 startBeans(true)
      • lifecycleBeans = getLifecycleBeans():获取到所有实现了 Lifecycle 接口的对象包装到 Map 内,key 是beanName, value 是 Lifecycle 对象
      • int phase = getPhase(bean):获取当前 Lifecycle 的 phase 值,当前生命周期对象可能依赖其他生命周期对象的执行结果,所以需要 phase 决定执行顺序,数值越低的优先执行
      • LifecycleGroup group = phases.get(phase):把 phsae 相同的 Lifecycle 存入 LifecycleGroup
      • if (group == null):group 为空则创建,初始情况下是空的
      • group.add(beanName, bean):将当前 Lifecycle 添加到当前 phase 值一样的 group 内
      • Collections.sort(keys)从小到大排序,按优先级启动
      • phases.get(key).start():遍历所有的 Lifecycle 对象开始启动
      • doStart(this.lifecycleBeans, member.name, this.autoStartupOnly):底层调用该方法启动
        • bean = lifecycleBeans.remove(beanName): 确保 Lifecycle 只被启动一次,在一个分组内被启动了在其他分组内就看不到 Lifecycle 了
        • dependenciesForBean = getBeanFactory().getDependenciesForBean(beanName):获取当前即将被启动的 Lifecycle 所依赖的其他 beanName,需要先启动所依赖的 bean,才能启动自身
        • if ():传入的参数 autoStartupOnly 为 true 表示启动 isAutoStartUp 为 true 的 SmartLifecycle 对象,不会启动普通的生命周期的对象;false 代表全部启动
        • bean.start():调用启动方法
    • publishEvent(new ContextRefreshedEvent(this))发布容器刷新完成事件
    • liveBeansView.registerApplicationContext(this):暴露 Mbean

补充生命周期 stop() 方法的调用

  • DefaultLifecycleProcessor.stop():调用 DefaultLifecycleProcessor.stopBeans()

    • 获取到所有实现了 Lifecycle 接口的对象并按 phase 数值分组的

    • keys.sort(Collections.reverseOrder()):按 phase 降序排序 Lifecycle 接口,最先启动的最晚关闭(责任链?)

    • phases.get(key).stop():遍历所有的 Lifecycle 对象开始停止

      • latch = new CountDownLatch(this.smartMemberCount):创建 CountDownLatch,设置 latch 内部的值为当前分组内的 smartMemberCount 的数量

      • countDownBeanNames = Collections.synchronizedSet(new LinkedHashSet<>()):保存当前正在处理关闭的smartLifecycle 的 BeanName

      • for (LifecycleGroupMember member : this.members):处理本分组内需要关闭的 Lifecycle

        doStop(this.lifecycleBeans, member.name, latch, countDownBeanNames):真正的停止方法

        • getBeanFactory().getDependentBeans(beanName)获取依赖当前 Lifecycle 的其他对象的 beanName,因为当前的 Lifecycle 即将要关闭了,所有的依赖了当前 Lifecycle 的 bean 也要关闭

        • countDownBeanNames.add(beanName):将当前 SmartLifecycle beanName 添加到 countDownBeanNames 集合内,该集合表示正在关闭的 SmartLifecycle

        • bean.stop():调用停止的方法


获取Bean

单实例:在容器启动时创建对象

多实例:在每次获取的时候创建对象

获取流程:获取 Bean 时先从单例池获取,如果没有则进行第二次获取,并带上工厂类去创建并添加至单例池

Java 启动 Spring 代码:

1
2
ApplicationContext ctx = new ClassPathXmlApplicationContext("applicationContext.xml");
UserService userService = (UserService) context.getBean("userService");

AbstractBeanFactory.doGetBean():获取 Bean,context.getBean() 追踪到此

  • beanName = transformedBeanName(name):name 可能是一个别名,重定向出来真实 beanName;也可能是一个 & 开头的 name,说明要获取的 bean 实例对象,是一个 FactoryBean 对象(IOC 原理 → 核心类)

    • BeanFactoryUtils.transformedBeanName(name):判断是哪种 name,返回截取 & 以后的 name 并放入缓存
      • transformedBeanNameCache.computeIfAbsent:缓存是并发安全集合,key == null || value == null 时 put 成功
      • do while 循环一直去除 & 直到不再含有 &
    • canonicalName(name):aliasMap 保存别名信息,其中的 do while 逻辑是迭代查找,比如 A 别名叫做 B,但是 B 又有别名叫 C, aliasMap 为 {“C”:”B”, “B”:”A”},get(C) 最后返回的是 A
  • DefaultSingletonBeanRegistry.getSingleton()第一次获取从缓存池获取(循环依赖详解此代码)

    • 缓存中有数据进行 getObjectForBeanInstance() 获取可使用的 Bean(本节结束部分详解此函数)
    • 缓存中没有数据进行下面的逻辑进行创建
  • if(isPrototypeCurrentlyInCreation(beanName)):检查 bean 是否在原型(Prototype)正在被创建的集合中,如果是就报错,说明产生了循环依赖,原型模式解决不了循环依赖

    原因:先加载 A,把 A 加入集合,A 依赖 B 去加载 B,B 又依赖 A,去加载 A,发现 A 在正在创建集合中,产生循环依赖

  • markBeanAsCreated(beanName):把 bean 标记为已经创建,防止其他线程重新创建 Bean

  • mbd = getMergedLocalBeanDefinition(beanName)获取合并父 BD 后的 BD 对象,BD 是直接继承的,合并后的 BD 信息是包含父类的 BD 信息

    • this.mergedBeanDefinitions.get(beanName):从缓存中获取

    • if(bd.getParentName()==null):beanName 对应 BD 没有父 BD 就不用处理继承,封装为 RootBeanDefinition 返回

    • parentBeanName = transformedBeanName(bd.getParentName()):处理父 BD 的 name 信息

    • if(!beanName.equals(parentBeanName)):一般情况父子 BD 的名称不同

      pbd = getMergedBeanDefinition(parentBeanName):递归调用,最终返回父 BD 的父 BD 信息

    • mbd = new RootBeanDefinition(pbd):按照父 BD 信息创建 RootBeanDefinition 对象

    • mbd.overrideFrom(bd)子 BD 信息覆盖 mbd,因为是要以子 BD 为基准,不存在的才去父 BD 寻找(类似 Java 继承

    • this.mergedBeanDefinitions.put(beanName, mbd):放入缓存

  • checkMergedBeanDefinition():判断当前 BD 是否为抽象 BD,抽象 BD 不能创建实例,只能作为父 BD 被继承

  • mbd.getDependsOn():获取 bean 标签 depends-on

  • if(dependsOn != null)遍历所有的依赖加载,解决不了循环依赖

    isDependent(beanName, dep):判断循环依赖,出现循环依赖问题报错

    • 两个 Map:<bean name="A" depends-on="B" ...>

      • dependentBeanMap:记录依赖了当前 beanName 的其他 beanName(谁依赖我,我记录谁)
      • dependenciesForBeanMap:记录当前 beanName 依赖的其它 beanName
      • 以 B 为视角 dependentBeanMap {“B”:{“A”}},以 A 为视角 dependenciesForBeanMap {“A” :{“B”}}
    • canonicalName(beanName):处理 bean 的 name

    • dependentBeans = this.dependentBeanMap.get(canonicalName):获取依赖了当前 bean 的 name

    • if (dependentBeans.contains(dependentBeanName)):依赖了当前 bean 的集合中是否有该 name,有就产生循环依赖

    • 进行递归处理所有的引用:假如 <bean name="A" dp="B"> <bean name="B" dp="C"> <bean name="C" dp="A">

      1
      2
      3
      dependentBeanMap={A:{C}, B:{A}, C:{B}} 
      // C 依赖 A 判断谁依赖了C 递归判断 谁依赖了B
      isDependent(C, A) → C#dependentBeans={B} → isDependent(B, A); → B#dependentBeans={A} //返回true

    registerDependentBean(dep, beanName):把 bean 和依赖注册到两个 Map 中,注意参数的位置,被依赖的在前

    getBean(dep)先加载依赖的 Bean,又进入 doGetBean() 的逻辑

  • if (mbd.isSingleton())判断 bean 是否是单例的 bean

    getSingleton(String, ObjectFactory<?>)第二次获取,传入一个工厂对象,这个方法更倾向于创建实例并返回

    1
    2
    3
    4
    sharedInstance = getSingleton(beanName, () -> {
    return createBean(beanName, mbd, args);//创建,跳转生命周期
    //lambda表达式,调用了ObjectFactory的getObject()方法,实际回调接口实现的是 createBean()方法进行创建对象
    });
    • singletonObjects.get(beanName):从一级缓存检查是否已经被加载,单例模式复用已经创建的 bean

    • this.singletonsCurrentlyInDestruction:容器销毁时会设置这个属性为 true,这时就不能再创建 bean 实例了

    • beforeSingletonCreation(beanName):检查构造注入的依赖,构造参数注入产生的循环依赖无法解决

      !this.singletonsCurrentlyInCreation.add(beanName):将当前 beanName 放入到正在创建中单实例集合,放入成功说明没有产生循环依赖,失败则产生循环依赖,进入判断条件内的逻辑抛出异常

      原因:加载 A,向正在创建集合中添加了 {A},根据 A 的构造方法实例化 A 对象,发现 A 的构造方法依赖 B,然后加载 B,B 构造方法的参数依赖于 A,又去加载 A 时来到当前方法,因为创建中集合已经存在 A,所以添加失败

    • singletonObject = singletonFactory.getObject()创建 bean(生命周期部分详解)

    • 创建完成以后,Bean 已经初始化好,是一个完整的可使用的 Bean

    • afterSingletonCreation(beanName):从正在创建中的集合中移出

    • addSingleton(beanName, singletonObject)添加一级缓存单例池中,从二级三级缓存移除

    bean = getObjectForBeanInstance单实例可能是普通单实例或者 FactoryBean,如果是 FactoryBean 实例,需要判断 name 是带 & 还是不带 &,带 & 说明 getBean 获取 FactoryBean 对象,否则是获取 FactoryBean 内部管理的实例

    • 参数 name 是未处理 & 的 name,beanName 是处理过 & 和别名后的 name

    • if(BeanFactoryUtils.isFactoryDereference(name)):判断 doGetBean 中参数 name 前是否带 &,不是处理后的

    • if(!(beanInstance instanceof FactoryBean) || BeanFactoryUtils.isFactoryDereference(name)):Bean 是普通单实例或者是 FactoryBean 就可以直接返回,否则进入下面的获取 FactoryBean 内部管理的实例的逻辑

    • getCachedObjectForFactoryBean(beanName):尝试到缓存获取,获取到直接返回,获取不到进行下面逻辑

    • if (mbd == null && containsBeanDefinition(beanName)):Spring 中有当前 beanName 的 BeanDefinition 信息

      mbd = getMergedLocalBeanDefinition(beanName):获取合并后的 BeanDefinition

    • mbd.isSynthetic():默认值是 false 表示这是一个用户对象,如果是 true 表示是系统对象

    • object = getObjectFromFactoryBean(factory, beanName, !synthetic):从工厂内获取实例

      • factory.isSingleton() && containsSingleton(beanName):工厂内部维护的对象是单实例并且一级缓存存在该 bean
      • 首先去缓存中获取,获取不到就使用工厂获取然后放入缓存,进行循环依赖判断
  • else if (mbd.isPrototype())bean 是原型的 bean

    beforePrototypeCreation(beanName):当前线程正在创建的原型对象 beanName 存入 prototypesCurrentlyInCreation

    • curVal = this.prototypesCurrentlyInCreation.get():获取当前线程的正在创建的原型类集合
    • this.prototypesCurrentlyInCreation.set(beanName):集合为空就把当前 beanName 加入
    • if (curVal instanceof String):已经有线程相关原型类创建了,把当前的创建的加进去

    createBean(beanName, mbd, args):创建原型类对象,不需要三级缓存

    afterPrototypeCreation(beanName):从正在创建中的集合中移除该 beanName, 与 beforePrototypeCreation逻辑相反

  • convertIfNecessary()依赖检查,检查所需的类型是否与实际 bean 实例的类型匹配

  • return (T) bean:返回创建完成的 bean


生命周期

四个阶段

Bean 的生命周期:实例化 instantiation,填充属性 populate,初始化 initialization,销毁 destruction

AbstractAutowireCapableBeanFactory.createBean():进入 Bean 生命周期的流程

  • resolvedClass = resolveBeanClass(mbd, beanName):判断 mdb 中的 class 是否已经加载到 JVM,如果未加载则使用类加载器将 beanName 加载到 JVM中并返回 class 对象

  • if (resolvedClass != null && !mbd.hasBeanClass() && mbd.getBeanClassName() != null):条件成立封装 mbd 并把 resolveBeanClass 设置到 bd 中

    • 条件二:mbd 在 resolveBeanClass 之前是否有 class
    • 条件三:mbd 有 className
  • bean = resolveBeforeInstantiation(beanName, mbdToUse):实例化前的后置处理器返回一个代理实例对象(不是 AOP)

    • 自定义类继承 InstantiationAwareBeanPostProcessor,重写 postProcessBeforeInstantiation 方法,方法逻辑为创建对象
    • 并配置文件 <bean class="intefacePackage.MyInstantiationAwareBeanPostProcessor"> 导入为 bean
    • 条件成立,短路操作,直接 return bean
  • Object beanInstance = doCreateBean(beanName, mbdToUse, args):Do it

AbstractAutowireCapableBeanFactory.doCreateBean(beanName, RootBeanDefinition, Object[] args):创建 Bean

  • BeanWrapper instanceWrapper = nullSpring 给所有创建的 Bean 实例包装成 BeanWrapper,内部最核心的方法是获取实例,提供了一些额外的接口方法,比如属性访问器

  • instanceWrapper = this.factoryBeanInstanceCache.remove(beanName):单例对象尝试从缓存中获取,会移除缓存

  • createBeanInstance()缓存中没有实例就进行创建实例(逻辑复杂,下一小节详解)

  • if (!mbd.postProcessed):每个 bean 只进行一次该逻辑

    applyMergedBeanDefinitionPostProcessors()后置处理器,合并 bd 信息,接下来要属性填充了

    AutowiredAnnotationBeanPostProcessor.postProcessMergedBeanDefinition()后置处理逻辑(@Autowired)

    • metadata = findAutowiringMetadata(beanName, beanType, null):提取当前 bean 整个继承体系内的 @Autowired、@Value、@Inject 信息,存入一个 InjectionMetadata 对象,保存着当前 bean 信息和要自动注入的字段信息

      1
      2
      private final Class<?> targetClass;							//当前 bean 
      private final Collection<InjectedElement> injectedElements; //要注入的信息集合
      • metadata = buildAutowiringMetadata(clazz):查询当前 clazz 感兴趣的注解信息

        • ReflectionUtils.doWithLocalFields():提取字段的注解的信息

          findAutowiredAnnotation(field):代表感兴趣的注解就是那三种注解,获取这三种注解的元数据

        • ReflectionUtils.doWithLocalMethods():提取方法的注解的信息

        • do{} while (targetClass != null && targetClass != Object.class):循环从父类中解析,直到 Object 类

      • this.injectionMetadataCache.put(cacheKey, metadata):存入缓存

    mbd.postProcessed = true:设置为 true,下次访问该逻辑不会再进入

  • earlySingletonExposure = (mbd.isSingleton() && this.allowCircularReferences && isSingletonCurrentlyInCreation(beanName):单例、解决循环引用、是否在单例正在创建集合中

    1
    2
    3
    4
    5
    if (earlySingletonExposure) {
    // 【放入三级缓存一个工厂对象,用来获取提前引用】
    addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
    // lamda 表达式,用来获取提前引用,循环依赖部分详解该逻辑
    }
  • populateBean(beanName, mbd, instanceWrapper):**属性填充,依赖注入,整体逻辑是先处理标签再处理注解,填充至 pvs 中,最后通过 apply 方法最后完成属性依赖注入到 BeanWrapper **

    • if (!ibp.postProcessAfterInstantiation(bw.getWrappedInstance(), beanName)):实例化后的后置处理器,默认返回 true,可以自定义类继承 InstantiationAwareBeanPostProcessor 修改后置处理方法的返回值为 false,使 continueWithPropertyPopulation 为 false,会导致直接返回,不进行属性的注入

    • if (!continueWithPropertyPopulation):自定义方法返回值会造成该条件成立,逻辑为直接返回,不进行依赖注入

    • PropertyValues pvs = (mbd.hasPropertyValues() ? mbd.getPropertyValues() : null):处理依赖注入逻辑开始

    • mbd.getResolvedAutowireMode() == ?根据 bean 标签配置的 autowire 判断是 BY_NAME 或者 BY_TYPE

      autowireByName(beanName, mbd, bw, newPvs):根据字段名称去获取依赖的 bean,还没注入,只是添加到 pvs

      • propertyNames = unsatisfiedNonSimpleProperties(mbd, bw):bean 实例中有该字段和该字段的 setter 方法,但是在 bd 中没有 property 属性

        • 拿到配置的 property 信息和 bean 的所有字段信息

        • pd.getWriteMethod() != null当前字段是否有 set 方法,配置类注入的方式需要 set 方法

          !isExcludedFromDependencyCheck(pd):当前字段类型是否在忽略自动注入的列表中

          !pvs.contains(pd.getName():当前字段不在 xml 或者其他方式的配置中,也就是 bd 中不存在对应的 property

          !BeanUtils.isSimpleProperty(pd.getPropertyType():是否是基本数据类型和内置的几种数据类型,基本数据类型不允许自动注入

      • if (containsBean(propertyName)):BeanFactory 中存在当前 property 的 bean 实例,说明找到对应的依赖数据

      • getBean(propertyName)拿到 propertyName 对应的 bean 实例

      • pvs.add(propertyName, bean):填充到 pvs 中

      • registerDependentBean(propertyName, beanName)):添加到两个依赖 Map(dependsOn)中

      autowireByType(beanName, mbd, bw, newPvs):根据字段类型去查找依赖的 bean

      • desc = new AutowireByTypeDependencyDescriptor(methodParam, eager):依赖描述信息
      • resolveDependency(desc, beanName, autowiredBeanNames, converter):根据描述信息,查找依赖对象,容器中没有对应的实例但是有对应的 BD,会调用 getBean(Type) 获取对象

      pvs = newPvs:newPvs 是处理了依赖数据后的 pvs,所以赋值给 pvs

    • hasInstAwareBpps:表示当前是否有 InstantiationAwareBeanPostProcessors 的后置处理器(Autowired)

    • pvsToUse = ibp.postProcessProperties(pvs, bw.getWrappedInstance(), beanName):**@Autowired 注解的注入**,这个传入的 pvs 对象,最后原封不动的返回,不会添加东西

      • findAutowiringMetadata():包装着当前 bd 需要注入的注解信息集合,三种注解的元数据,直接缓存获取

      • InjectionMetadata.InjectedElement.inject():遍历注解信息解析后注入到 Bean,方法和字段的注入实现不同

        以字段注入为例:

        • value = resolveFieldValue(field, bean, beanName):处理字段属性值

          value = beanFactory.resolveDependency():解决依赖

          result = doResolveDependency()真正处理自动注入依赖的逻辑

          • Object shortcut = descriptor.resolveShortcut(this):默认返回 null

          • Object value = getAutowireCandidateResolver().getSuggestedValue(descriptor)获取 @Value 的值

          • converter.convertIfNecessary(value, type, descriptor.getTypeDescriptor()):如果 value 不是 null,就直接进行类型转换返回数据

          • matchingBeans = findAutowireCandidates(beanName, type, descriptor):如果 value 是空说明字段是引用类型,获取 @Autowired 的 Bean

            1
            2
            3
            4
            5
            // addCandidateEntry() → Object beanInstance = descriptor.resolveCandidate()
            public Object resolveCandidate(String beanName, Class<?> requiredType, BeanFactory beanFactory) throws BeansException {
            // 获取 bean
            return beanFactory.getBean(beanName);
            }
        • ReflectionUtils.makeAccessible(field):修改访问权限

        • field.set(bean, value):获取属性访问器为此 field 对象赋值

    • applyPropertyValues()将所有解析的 PropertyValues 的注入至 BeanWrapper 实例中(深拷贝)

      • if (pvs.isEmpty()):注解 @Autowired 和 @Value 标注的信息在后置处理的逻辑注入完成,此处为空直接返回
      • 下面的逻辑进行 XML 配置的属性的注入,首先获取转换器进行数据转换,然后获取 WriteMethod (set) 方法进行反射调用,完成属性的注入
  • initializeBean(String,Object,RootBeanDefinition)初始化,分为配置文件和实现接口两种方式

    • invokeAwareMethods(beanName, bean):根据 bean 是否实现 Aware 接口执行初始化的方法

    • wrappedBean = applyBeanPostProcessorsBeforeInitialization:初始化前的后置处理器,可以继承接口重写方法

      • processor.postProcessBeforeInitialization():执行后置处理的方法,默认返回 bean 本身
      • if (current == null) return result:重写方法返回 null,会造成后置处理的短路,直接返回
    • invokeInitMethods(beanName, wrappedBean, mbd)反射执行初始化方法

      • isInitializingBean = (bean instanceof InitializingBean):初始化方法的定义有两种方式,一种是自定义类实现 InitializingBean 接口,另一种是配置文件配置 <bean id=”…” class=”…” init-method=”init”/ >

      • isInitializingBean && (mbd == null || !mbd.isExternallyManagedInitMethod("afterPropertiesSet"))

        • 条件一:当前 bean 是不是实现了 InitializingBean

        • 条件二:InitializingBean 接口中的方法 afterPropertiesSet,判断该方法是否是容器外管理的方法

      • if (mbd != null && bean.getClass() != NullBean.class):成立说明是配置文件的方式

        if(!(接口条件))表示如果通过接口实现了初始化方法的话,就不会在调用配置类中 init-method 定义的方法

        ((InitializingBean) bean).afterPropertiesSet():调用方法

        invokeCustomInitMethod:执行自定义的方法

        • initMethodName = mbd.getInitMethodName():获取方法名
        • Method initMethod = ():根据方法名获取到 init-method 方法
        • methodToInvoke = ClassUtils.getInterfaceMethodIfPossible(initMethod):将方法转成从接口层面获取
        • ReflectionUtils.makeAccessible(methodToInvoke):访问权限设置成可访问
        • methodToInvoke.invoke(bean)反射调用初始化方法,以当前 bean 为角度去调用
    • wrappedBean = applyBeanPostProcessorsAfterInitialization:初始化后的后置处理器

      • AbstractAutoProxyCreator.postProcessAfterInitialization():如果 Bean 被子类标识为要代理的 bean,则使用配置的拦截器创建代理对象,AOP 部分详解

      • 如果不存在循环依赖,创建动态代理 bean 在此处完成;否则真正的创建阶段是在属性填充时获取提前引用的阶段,循环依赖详解,源码分析:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        // 该集合用来避免重复将某个 bean 生成代理对象,
        private final Map<Object, Object> earlyProxyReferences = new ConcurrentHashMap<>(16);

        public Object postProcessAfterInitialization(@Nullable Object bean,String bN){
        if (bean != null) {
        // cacheKey 是 beanName 或者加上 &
        Object cacheKey = getCacheKey(bean.getClass(), beanName);y
        if (this.earlyProxyReferences.remove(cacheKey) != bean) {
        // 去提前代理引用池中寻找该key,不存在则创建代理
        // 如果存在则证明被代理过,则判断是否是当前的 bean,不是则创建代理
        return wrapIfNecessary(bean, bN, cacheKey);
        }
        }
        return bean;
        }
  • if (earlySingletonExposure):是否允许提前引用

    earlySingletonReference = getSingleton(beanName, false)从二级缓存获取实例,放入一级缓存是在 doGetBean 中的sharedInstance = getSingleton() 逻辑中,此时在 createBean 的逻辑还没有返回,所以一级缓存没有

    if (earlySingletonReference != null):当前 bean 实例从二级缓存中获取到了,说明产生了循环依赖,在属性填充阶段会提前调用三级缓存中的工厂生成 Bean 的代理对象(或原始实例),放入二级缓存中,然后使用原始 bean 继续执行初始化

    • if (exposedObject == bean)初始化后的 bean == 创建的原始实例,条件成立的两种情况:当前的真实实例不需要被代理;当前实例存在循环依赖已经被提前代理过了,初始化时的后置处理器直接返回 bean 原实例

      exposedObject = earlySingletonReference把代理后的 Bean 传给 exposedObject 用来返回,因为只有代理对象才封装了拦截器链,main 方法中用代理对象调用方法时会进行增强,代理是对原始对象的包装,所以这里返回的代理对象中含有完整的原实例(属性填充和初始化后的),是一个完整的代理对象,返回后外层方法会将当前 Bean 放入一级缓存

    • else if (!this.allowRawInjectionDespiteWrapping && hasDependentBean(beanName)):是否有其他 bean 依赖当前 bean,执行到这里说明是不存在循环依赖、存在增强代理的逻辑,也就是正常的逻辑

      • dependentBeans = getDependentBeans(beanName):取到依赖当前 bean 的其他 beanName

      • if (!removeSingletonIfCreatedForTypeCheckOnly(dependentBean)):判断 dependentBean 是否创建完成

        • if (!this.alreadyCreated.contains(beanName)):成立当前 bean 尚未创建完成,当前 bean 是依赖exposedObject 的 bean,返回 true
      • return false:创建完成返回 false

        actualDependentBeans.add(dependentBean):创建完成的 dependentBean 加入该集合

      • if (!actualDependentBeans.isEmpty()):条件成立说明有依赖于当前 bean 的 bean 实例创建完成,但是当前的 bean 还没创建完成返回,依赖当前 bean 的外部 bean 持有的是不完整的 bean,所以需要报错

  • registerDisposableBeanIfNecessary:判断当前 bean 是否需要注册析构函数回调,当容器销毁时进行回调

    • if (!mbd.isPrototype() && requiresDestruction(bean, mbd))

      • 如果是原型 prototype 不会注册析构回调,不会回调该函数,对象的回收由 JVM 的 GC 机制完成

      • requiresDestruction():

        • DisposableBeanAdapter.hasDestroyMethod(bean, mbd):bd 中定义了 DestroyMethod 返回 true

        • hasDestructionAwareBeanPostProcessors():后处理器框架决定是否进行析构回调

    • registerDisposableBean():条件成立进入该方法,给当前单实例注册回调适配器,适配器内根据当前 bean 实例是继承接口(DisposableBean)还是自定义标签来判定具体调用哪个方法实现

  • this.disposableBeans.put(beanName, bean):向销毁集合添加实例


创建实例

AbstractAutowireCapableBeanFactory.createBeanInstance(beanName, RootBeanDefinition, Object[] args)

  • resolveBeanClass(mbd, beanName):确保 Bean 的 Class 真正的被加载

  • 判断类的访问权限是不是 public,不是进入下一个判断,是否允许访问类的 non-public 的构造方法,不允许则报错

  • Supplier<?> instanceSupplier = mbd.getInstanceSupplier():获取创建实例的函数,可以自定义,没有进入下面的逻辑

  • if (mbd.getFactoryMethodName() != null)判断 bean 是否设置了 factory-method 属性,优先使用

    ,设置了该属性进入 factory-method 方法创建实例

  • resolved = false:代表 bd 对应的构造信息是否已经解析成可以反射调用的构造方法

  • autowireNecessary = false:是否自动匹配构造方法

  • if(mbd.resolvedConstructorOrFactoryMethod != null):获取 bd 的构造信息转化成反射调用的 method 信息

    • method 为 null 则 resolved 和 autowireNecessary 都为默认值 false
    • autowireNecessary = mbd.constructorArgumentsResolved:构造方法有参数,设置为 true
  • bd 对应的构造信息解析完成,可以直接反射调用构造方法了

    • return autowireConstructor(beanName, mbd, null, null)有参构造,根据参数匹配最优的构造器创建实例

    • return instantiateBean(beanName, mbd)无参构造方法通过反射创建实例

      • SimpleInstantiationStrategy.instantiate()真正用来实例化的函数(无论如何都会走到这一步)

        • if (!bd.hasMethodOverrides()):没有方法重写覆盖

          BeanUtils.instantiateClass(constructorToUse):调用 Constructor.newInstance() 实例化

        • instantiateWithMethodInjection(bd, beanName, owner)有方法重写采用 CGLIB 实例化

      • BeanWrapper bw = new BeanWrapperImpl(beanInstance):包装成 BeanWrapper 类型的对象

      • return bw:返回实例

  • ctors = determineConstructorsFromBeanPostProcessors(beanClass, beanName):**@Autowired 注解**,对应的后置处理器 AutowiredAnnotationBeanPostProcessor 逻辑

    • 配置了 lookup 的相关逻辑

    • this.candidateConstructorsCache.get(beanClass):从缓存中获取构造方法,第一次获取为 null,进入下面逻辑

    • rawCandidates = beanClass.getDeclaredConstructors():获取所有的构造器

    • Constructor<?> requiredConstructor = null:唯一的选项构造器,**@Autowired(required = “true”)** 时有值

    • for (Constructor<?> candidate : rawCandidates):遍历所有的构造器:

      ann = findAutowiredAnnotation(candidate):有三种注解中的一个会返回注解的属性

      • 遍历 this.autowiredAnnotationTypes 中的三种注解:

        1
        2
        3
        this.autowiredAnnotationTypes.add(Autowired.class);//!!!!!!!!!!!!!!
        this.autowiredAnnotationTypes.add(Value.class);
        this.autowiredAnnotationTypes.add(...ClassUtils.forName("javax.inject.Inject"));
      • AnnotatedElementUtils.getMergedAnnotationAttributes(ao, type):获取注解的属性

      • if (attributes != null) return attributes:任意一个注解属性不为空就注解返回

      if (ann == null):注解属性为空

      • userClass = ClassUtils.getUserClass(beanClass):如果当前 beanClass 是代理对象,方法上就已经没有注解了,所以获取原始的用户类型重新获取该构造器上的注解属性事务注解失效也是这个原理)

      if (ann != null):注解属性不为空了

      • required = determineRequiredStatus(ann):获取 required 属性的值

        • !ann.containsKey(this.requiredParameterName) || :判断属性是否包含 required,不包含进入后面逻辑
        • this.requiredParameterValue == ann.getBoolean(this.requiredParameterName):获取属性值返回
      • if (required):代表注解 @Autowired(required = true)

        if (!candidates.isEmpty()):true 代表只能有一个构造方法,构造集合不是空代表可选的构造器不唯一,报错

        requiredConstructor = candidate:把构造器赋值给 requiredConstructor

      • candidates.add(candidate):把当前构造方法添加至 candidates 集合

      if(candidate.getParameterCount() == 0):当前遍历的构造器的参数为 0 代表没有参数,是默认构造器,赋值给 defaultConstructor

    • candidateConstructors = candidates.toArray(new Constructor<?>[0])将构造器转成数组返回

  • if(ctors != null):条件成立代表指定了构造方法数组

    mbd.getResolvedAutowireMode() == AUTOWIRE_CONSTRUCTOR 标签内 autowiremode 的属性值,默认是 no,AUTOWIRE_CONSTRUCTOR 代表选择最优的构造方法

    mbd.hasConstructorArgumentValues():bean 信息中是否配置了构造参数的值

    !ObjectUtils.isEmpty(args):getBean 时,指定了参数 arg

  • return autowireConstructor(beanName, mbd, ctors, args)选择最优的构造器进行创建实例(复杂,不建议研究)

    • beanFactory.initBeanWrapper(bw):向 BeanWrapper 中注册转换器,向工厂中注册属性编辑器

    • Constructor<?> constructorToUse = null:实例化反射构造器

      ArgumentsHolder argsHolderToUse:实例化时真正去用的参数,并持有对象

      • rawArguments 是转换前的参数,arguments 是类型转换完成的参数

      Object[] argsToUse:参数实例化时使用的参数

    • Object[] argsToResolve:表示构造器参数做转换后的参数引用

    • if (constructorToUse != null && mbd.constructorArgumentsResolved)

      • 条件一成立说明当前 bd 生成的实例不是第一次,缓存中有解析好的构造器方法可以直接拿来反射调用
      • 条件二成立说明构造器参数已经解析过了
    • argsToUse = resolvePreparedArguments():argsToResolve 不是完全解析好的,还需要继续解析

    • if (constructorToUse == null || argsToUse == null):条件成立说明缓存机制失败,进入构造器匹配逻辑

    • Constructor<?>[] candidates = chosenCtors:chosenCtors 只有在构造方法上有 autowaire 三种注解时才有数据

    • if (candidates == null):candidates 为空就根据 beanClass 是否允许访问非公开的方法来获取构造方法

    • if (candidates.length == 1 && explicitArgs == null && !mbd.hasConstructorArgumentValues()):默认无参

      bw.setBeanInstance(instantiate())使用无参构造器反射调用,创建出实例对象,设置到 BeanWrapper 中去

    • boolean autowiring需要选择最优的构造器

    • cargs = mbd.getConstructorArgumentValues():获取参数值

      resolvedValues = new ConstructorArgumentValues():获取已经解析后的构造器参数值

      • final Map<Integer, ValueHolder> indexedArgumentValues:key 是 index, value 是值
      • final List<ValueHolder> genericArgumentValues:没有 index 的值

      minNrOfArgs = resolveConstructorArguments(..,resolvedValues):从 bd 中解析并获取构造器参数的个数

      • valueResolver.resolveValueIfNecessary():将引用转换成真实的对象
      • resolvedValueHolder.setSource(valueHolder):将对象填充至 ValueHolder 中
      • resolvedValues.addIndexedArgumentValue():将参数值封装至 resolvedValues 中
    • AutowireUtils.sortConstructors(candidates):排序规则 public > 非公开的 > 参数多的 > 参数少的

    • int minTypeDiffWeight = Integer.MAX_VALUE:值越低说明构造器参数列表类型和构造参数的匹配度越高

    • Set<Constructor<?>> ambiguousConstructors:模棱两可的构造器,两个构造器匹配度相等时放入

    • for (Constructor<?> candidate : candidates):遍历筛选出 minTypeDiffWeight 最低的构造器

    • Class<?>[] paramTypes = candidate.getParameterTypes():获取当前处理的构造器的参数类型

    • if():candidates 是排过序的,当前筛选出来的构造器的优先级一定是优先于后面的 constructor

    • if (paramTypes.length < minNrOfArgs):需求的小于给的,不匹配

    • int typeDiffWeight:获取匹配度

      • mbd.isLenientConstructorResolution():true 表示 ambiguousConstructors 允许有数据,false 代表不允许有数据,有数据就报错(LenientConstructorResolution:宽松的构造函数解析)
      • argsHolder.getTypeDifferenceWeight(paramTypes):选择参数转换前和转换后匹配度最低的,循环向父类中寻找该方法,直到寻找到 Obejct 类
    • if (typeDiffWeight < minTypeDiffWeight):条件成立说明当前循环处理的构造器更优

    • else if (constructorToUse != null && typeDiffWeight == minTypeDiffWeight):当前处理的构造器的计算出来的 DiffWeight 与上一次筛选出来的最优构造器的值一致,说明有模棱两可的情况

    • if (constructorToUse == null):未找到可以使用的构造器,报错

    • else if (ambiguousConstructors != null && !mbd.isLenientConstructorResolution()):模棱两可有数据,LenientConstructorResolution == false,所以报错

    • argsHolderToUse.storeCache(mbd, constructorToUse):匹配成功,进行缓存,方便后来者使用该 bd 实例化

    • bw.setBeanInstance(instantiate(beanName, mbd, constructorToUse, argsToUse)):匹配成功调用 instantiate 创建出实例对象,设置到 BeanWrapper 中去

  • return instantiateBean(beanName, mbd):默认走到这里


循环依赖

循环引用

循环依赖:是一个或多个对象实例之间存在直接或间接的依赖关系,这种依赖关系构成一个环形调用

Spring 循环依赖有四种:

  • DependsOn 依赖加载【无法解决】(两种 Map)
  • 原型模式 Prototype 循环依赖【无法解决】(正在创建集合)
  • 单例 Bean 循环依赖:构造参数产生依赖【无法解决】(正在创建集合,getSingleton() 逻辑中)
  • 单例 Bean 循环依赖:setter 产生依赖【可以解决】

解决循环依赖:提前引用,提前暴露创建中的 Bean

  • Spring 先实例化 A,拿到 A 的构造方法反射创建出来 A 的早期实例对象,这个对象被包装成 ObjectFactory 对象,放入三级缓存
  • 处理 A 的依赖数据,检查发现 A 依赖 B 对象,所以 Spring 就会去根据 B 类型到容器中去 getBean(B),这里产生递归
  • 拿到 B 的构造方法,进行反射创建出来 B 的早期实例对象,也会把 B 包装成 ObjectFactory 对象,放到三级缓存,处理 B 的依赖数据,检查发现 B 依赖了 A 对象,然后 Spring 就会去根据 A 类型到容器中去 getBean(A.class)
  • 这时从三级缓存中获取到 A 的早期对象进入属性填充

循环依赖的三级缓存:

1
2
3
4
5
6
7
8
//一级缓存:存放所有初始化完成单实例 bean,单例池,key是beanName,value是对应的单实例对象引用
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);

//二级缓存:存放实例化未进行初始化的 Bean,提前引用池
private final Map<String, Object> earlySingletonObjects = new HashMap<>(16);

/** Cache of singleton factories: bean name to ObjectFactory. 3*/
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);
  • 为什么需要三级缓存?

    • 循环依赖解决需要提前引用动态代理对象,AOP 动态代理是在 Bean 初始化后的后置处理中进行,这时的 bean 已经是成品对象。因为需要提前进行动态代理,三级缓存的 ObjectFactory 提前产生需要代理的对象,把提前引用放入二级缓存
    • 如果只有二级缓存,提前引用就直接放入了一级缓存,然后 Bean 初始化完成后又会放入一级缓存,产生数据覆盖,导致提前引用的对象和一级缓存中的并不是同一个对象
    • 一级缓存只能存放完整的单实例,为了保证 Bean 的生命周期不被破坏,不能将未初始化的 Bean 暴露到一级缓存
    • 若存在循环依赖,后置处理不创建代理对象,真正创建代理对象的过程是在 getBean(B) 的阶段中
  • 三级缓存一定会创建提前引用吗?

    • 出现循环依赖就会去三级缓存获取提前引用,不出现就不会,走正常的逻辑,创建完成直接放入一级缓存
    • 存在循环依赖,就创建代理对象放入二级缓存,如果没有增强方法就返回 createBeanInstance 创建的实例,因为 addSingletonFactory 参数中传入了实例化的 Bean,在 singletonFactory.getObject() 中返回给 singletonObject,所以存在循环依赖就一定会使用工厂,但是不一定创建的是代理对象,不需要增强就是原始对象
  • wrapIfNecessary 一定创建代理对象吗?(AOP 动态代理部分有源码解析)

    • 存在增强器会创建动态代理,不需要增强就不需要创建动态代理对象
    • 存在循环依赖会提前增强,初始化后不需要增强
  • 什么时候将 Bean 的引用提前暴露给第三级缓存的 ObjectFactory 持有?

    • 实例化之后,依赖注入之前

      1
      createBeanInstance -> addSingletonFactory -> populateBean

源码解析

假如 A 依赖 B,B 依赖 A

  • 当 A 创建实例后填充属性前,执行:

    1
    addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean))
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // 添加给定的单例工厂以构建指定的单例
    protected void addSingletonFactory(String beanName, ObjectFactory<?> singletonFactory) {
    Assert.notNull(singletonFactory, "Singleton factory must not be null");
    synchronized (this.singletonObjects) {
    // 单例池包含该Bean说明已经创建完成,不需要循环依赖
    if (!this.singletonObjects.containsKey(beanName)) {
    //加入三级缓存
    this.singletonFactories.put(beanName,singletonFactory);
    this.earlySingletonObjects.remove(beanName);
    // 从二级缓存移除,因为三个Map中都是一个对象,不能同时存在!
    this.registeredSingletons.add(beanName);
    }
    }
    }
  • 填充属性时 A 依赖 B,这时需要 getBean(B),也会把 B 的工厂放入三级缓存,接着 B 填充属性时发现依赖 A,去进行**第一次 ** getSingleton(A)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    public Object getSingleton(String beanName) {
    return getSingleton(beanName, true);//为true代表允许拿到早期引用。
    }
    protected Object getSingleton(String beanName, boolean allowEarlyReference) {
    // 在一级缓存中获取 beanName 对应的单实例对象。
    Object singletonObject = this.singletonObjects.get(beanName);
    // 单实例确实尚未创建;单实例正在创建,发生了循环依赖
    if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
    synchronized (this.singletonObjects) {
    // 从二级缓存获取
    singletonObject = this.earlySingletonObjects.get(beanName);
    // 二级缓存不存在,并且允许获取早期实例对象,去三级缓存查看
    if (singletonObject == null && allowEarlyReference) {
    ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName);
    if (singletonFactory != null) {
    // 从三级缓存获取工厂对象,并得到 bean 的提前引用
    singletonObject = singletonFactory.getObject();
    // 【缓存升级】,放入二级缓存,提前引用池
    this.earlySingletonObjects.put(beanName, singletonObject);
    // 从三级缓存移除该对象
    this.singletonFactories.remove(beanName);
    }
    }
    }
    }
    return singletonObject;
    }
  • 从三级缓存获取 A 的 Bean:singletonFactory.getObject(),调用了 lambda 表达式的 getEarlyBeanReference 方法:

    1
    2
    3
    4
    5
    6
    7
    public Object getEarlyBeanReference(Object bean, String beanName) {
    Object cacheKey = getCacheKey(bean.getClass(), beanName);
    // 【向提前引用代理池 earlyProxyReferences 中添加该 Bean,防止对象被重新代理】
    this.earlyProxyReferences.put(cacheKey, bean);
    // 创建代理对象,createProxy
    return wrapIfNecessary(bean, beanName, cacheKey);
    }
  • B 填充了 A 的提前引用后会继续初始化直到完成,返回原始 A 的逻辑继续执行


AOP

注解原理

@EnableAspectJAutoProxy:AOP 注解驱动,给容器中导入 AspectJAutoProxyRegistrar

1
2
3
4
5
6
7
8
9
10
@Import(AspectJAutoProxyRegistrar.class)
public @interface EnableAspectJAutoProxy {
// 是否强制使用 CGLIB 创建代理对象
// 配置文件方式:<aop:aspectj-autoproxy proxy-target-class="true"/>
boolean proxyTargetClass() default false;

// 将当前代理对象暴露到上下文内,方便代理对象内部的真实对象拿到代理对象
// 配置文件方式:<aop:aspectj-autoproxy expose-proxy="true"/>
boolean exposeProxy() default false;
}

AspectJAutoProxyRegistrar 在用来向容器中注册 AnnotationAwareAspectJAutoProxyCreator,以 BeanDefiantion 形式存在,在容器初始化时加载。AnnotationAwareAspectJAutoProxyCreator 间接实现了 InstantiationAwareBeanPostProcessor,Order 接口,该类会在 Bean 的实例化和初始化的前后起作用

工作流程:创建 IOC 容器,调用 refresh() 刷新容器,registerBeanPostProcessors(beanFactory) 阶段,通过 getBean() 创建 AnnotationAwareAspectJAutoProxyCreator 对象,在生命周期的初始化方法中执行回调 initBeanFactory() 方法初始化注册三个工具类:BeanFactoryAdvisorRetrievalHelperAdapter、ReflectiveAspectJAdvisorFactory、BeanFactoryAspectJAdvisorsBuilderAdapter


后置处理

Bean 初始化完成的执行后置处理器的方法:

1
2
3
4
5
6
7
8
9
10
11
12
public Object postProcessAfterInitialization(@Nullable Object bean,String bN){
if (bean != null) {
// cacheKey 是 【beanName 或者加上 & 的 beanName】
Object cacheKey = getCacheKey(bean.getClass(), beanName);
if (this.earlyProxyReferences.remove(cacheKey) != bean) {
// 去提前代理引用池中寻找该 key,不存在则创建代理
// 如果存在则证明被代理过,则判断是否是当前的 bean,不是则创建代理
return wrapIfNecessary(bean, bN, cacheKey);
}
}
return bean;
}

AbstractAutoProxyCreator.wrapIfNecessary():根据通知创建动态代理,没有通知直接返回原实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
protected Object wrapIfNecessary(Object bean, String beanName, Object cacheKey) {
// 条件一般不成立,很少使用 TargetSourceCreator 去创建对象 BeforeInstantiation 阶段,doCreateBean 之前的阶段
if (StringUtils.hasLength(beanName) && this.targetSourcedBeans.contains(beanName)) {
return bean;
}
// advisedBeans 集合保存的是 bean 是否被增强过了
// 条件成立说明当前 beanName 对应的实例不需要被增强处理,判断是在 BeforeInstantiation 阶段做的
if (Boolean.FALSE.equals(this.advisedBeans.get(cacheKey))) {
return bean;
}
// 条件一:判断当前 bean 类型是否是基础框架类型,这个类的实例不能被增强
// 条件二:shouldSkip 判断当前 beanName 是否是 .ORIGINAL 结尾,如果是就跳过增强逻辑,直接返回
if (isInfrastructureClass(bean.getClass()) || shouldSkip(bean.getClass(), beanName)) {
this.advisedBeans.put(cacheKey, Boolean.FALSE);
return bean;
}

// 【查找适合当前 bean 实例的增强方法】(下一节详解)
Object[] specificInterceptors = getAdvicesAndAdvisorsForBean(bean.getClass(), beanName, null);
// 条件成立说明上面方法查询到适合当前class的通知
if (specificInterceptors != DO_NOT_PROXY) {
this.advisedBeans.put(cacheKey, Boolean.TRUE);
// 根据查询到的增强创建代理对象(下一节详解)
// 参数一:目标对象
// 参数二:beanName
// 参数三:匹配当前目标对象 clazz 的 Advisor 数据
Object proxy = createProxy(
bean.getClass(), beanName, specificInterceptors, new SingletonTargetSource(bean));
// 保存代理对象类型
this.proxyTypes.put(cacheKey, proxy.getClass());
// 返回代理对象
return proxy;
}
// 执行到这里说明没有查到通知,当前 bean 不需要增强
this.advisedBeans.put(cacheKey, Boolean.FALSE);
// 【返回原始的 bean 实例】
return bean;
}

获取通知

AbstractAdvisorAutoProxyCreator.getAdvicesAndAdvisorsForBean():查找适合当前类实例的增强,并进行排序

1
2
3
4
5
6
7
8
9
10
protected Object[] getAdvicesAndAdvisorsForBean(Class<?> beanClass, String beanName, @Nullable TargetSource targetSource) {
// 查询适合当前类型的增强通知
List<Advisor> advisors = findEligibleAdvisors(beanClass, beanName);
if (advisors.isEmpty()) {
// 增强为空直接返回 null,不需要创建代理
return DO_NOT_PROXY;
}
// 不是空,转成数组返回
return advisors.toArray();
}

AbstractAdvisorAutoProxyCreator.findEligibleAdvisors():

  • candidateAdvisors = findCandidateAdvisors()获取当前容器内可以使用(所有)的 advisor,调用的是 AnnotationAwareAspectJAutoProxyCreator 类的方法,每个方法对应一个 Advisor

    • advisors = super.findCandidateAdvisors()查询出 XML 配置的所有 Advisor 类型

      • advisorNames = BeanFactoryUtils.beanNamesForTypeIncludingAncestors():通过 BF 查询出来 BD 配置的 class 中 是 Advisor 子类的 BeanName
      • advisors.add():使用 Spring 容器获取当前这个 Advisor 类型的实例
    • advisors.addAll(....buildAspectJAdvisors())获取所有添加 @Aspect 注解类中的 Advisor

      buildAspectJAdvisors():构建的方法,把 Advice 封装成 Advisor

      • beanNames = BeanFactoryUtils.beanNamesForTypeIncludingAncestors(this.beanFactory, Object.class, true, false):获取出容器内 Object 所有的 beanName,就是全部的

      • for (String beanName : beanNames):遍历所有的 beanName,判断每个 beanName 对应的 Class 是否是 Aspect 类型,就是加了 @Aspect 注解的类

        • factory = new BeanFactoryAspectInstanceFactory(this.beanFactory, beanName):使用工厂模式管理 Aspect 的元数据,关联的真实 @Aspect 注解的实例对象

        • classAdvisors = this.advisorFactory.getAdvisors(factory):添加了 @Aspect 注解的类的通知信息

          • aspectClass:@Aspect 标签的类的 class

          • for (Method method : getAdvisorMethods(aspectClass)):遍历不包括 @Pointcut 注解的方法

            Advisor advisor = getAdvisor(method, lazySingletonAspectInstanceFactory, advisors.size(), aspectName)将当前 method 包装成 Advisor 数据

            • AspectJExpressionPointcut expressionPointcut = getPointcut():获取切点表达式

            • return new InstantiationModelAwarePointcutAdvisorImpl():把 method 中 Advice 包装成 Advisor,Spring 中每个 Advisor 内部一定是持有一个 Advice 的,Advice 内部最重要的数据是当前 method 和aspectInstanceFactory,工厂用来获取实例

              this.instantiatedAdvice = instantiateAdvice(this.declaredPointcut):实例化 Advice 对象,逻辑是获取注解信息,根据注解的不同生成对应的 Advice 对象

        • advisors.addAll(classAdvisors):保存通过 @Aspect 注解定义的 Advisor 数据

      • this.aspectBeanNames = aspectNames:将所有 @Aspect 注解 beanName 缓存起来,表示提取 Advisor 工作完成

      • return advisors:返回 Advisor 列表

  • eligibleAdvisors = findAdvisorsThatCanApply(candidateAdvisors, ...)选出匹配当前类的增强

    • if (candidateAdvisors.isEmpty()):条件成立说明当前 Spring 没有可以操作的 Advisor

    • List<Advisor> eligibleAdvisors = new ArrayList<>():存放匹配当前 beanClass 的 Advisors 信息

    • for (Advisor candidate : candidateAdvisors)遍历所有的 Advisor

      if (canApply(candidate, clazz, hasIntroductions)):判断遍历的 advisor 是否匹配当前的 class,匹配就加入集合

      • if (advisor instanceof PointcutAdvisor):创建的 advisor 是 InstantiationModelAwarePointcutAdvisorImpl 类型

        PointcutAdvisor pca = (PointcutAdvisor) advisor:封装当前 Advisor

        return canApply(pca.getPointcut(), targetClass, hasIntroductions):重载该方法

        • if (!pc.getClassFilter().matches(targetClass))类不匹配 Pointcut 表达式,直接返回 false
        • methodMatcher = pc.getMethodMatcher()获取 Pointcut 方法匹配器,类匹配进行类中方法的匹配
        • Set<Class<?>> classes:保存目标对象 class 和目标对象父类超类的接口和自身实现的接口
        • if (!Proxy.isProxyClass(targetClass)):判断当前实例是不是代理类,确保 class 内存储的数据包括目标对象的class 而不是代理类的 class
        • for (Class<?> clazz : classes)检查目标 class 和上级接口的所有方法,查看是否会被方法匹配器匹配,如果有一个方法匹配成功,就说明目标对象 AOP 代理需要增强
          • specificMethod = AopUtils.getMostSpecificMethod(method, targetClass):方法可能是接口的,判断当前类有没有该方法
          • return (specificMethod != method && matchesMethod(specificMethod))类和方法的匹配,不包括参数
  • extendAdvisors(eligibleAdvisors):在 eligibleAdvisors 列表的索引 0 的位置添加 DefaultPointcutAdvisor,封装了 ExposeInvocationInterceptor 拦截器

  • eligibleAdvisors = sortAdvisors(eligibleAdvisors)对拦截器进行排序,数值越小优先级越高,高的排在前面

    • 实现 Ordered 或 PriorityOrdered 接口,PriorityOrdered 的级别要优先于 Ordered,使用 OrderComparator 比较器
    • 使用 @Order(Spring 规范)或 @Priority(JDK 规范)注解,使用 AnnotationAwareOrderComparator 比较器
    • ExposeInvocationInterceptor 实现了 PriorityOrdered ,所以总是排在第一位,MethodBeforeAdviceInterceptor 没实现任何接口,所以优先级最低,排在最后
  • return eligibleAdvisors:返回拦截器链


创建代理

AbstractAutoProxyCreator.createProxy():根据增强方法创建代理对象

  • ProxyFactory proxyFactory = new ProxyFactory()无参构造 ProxyFactory,此处讲解一下两种有参构造方法:

    • public ProxyFactory(Object target):

      1
      2
      3
      4
      5
      6
      public ProxyFactory(Object target) {
      // 将目标对象封装成 SingletonTargetSource 保存到父类的字段中
      setTarget(target);
      // 获取目标对象 class 所有接口保存到 AdvisedSupport 中的 interfaces 集合中
      setInterfaces(ClassUtils.getAllInterfaces(target));
      }

      ClassUtils.getAllInterfaces(target) 底层调用 getAllInterfacesForClassAsSet(java.lang.Class<?>, java.lang.ClassLoader):

      • if (clazz.isInterface() && isVisible(clazz, classLoader))
        • 条件一:判断当前目标对象是接口
        • 条件二:检查给定的类在给定的 ClassLoader 中是否可见
      • Class<?>[] ifcs = current.getInterfaces():拿到自己实现的接口,拿不到接口实现的接口
      • current = current.getSuperclass():递归寻找父类的接口,去获取父类实现的接口
    • public ProxyFactory(Class<?> proxyInterface, Interceptor interceptor):

      1
      2
      3
      4
      5
      6
      public ProxyFactory(Class<?> proxyInterface, Interceptor interceptor) {
      // 添加一个代理的接口
      addInterface(proxyInterface);
      // 添加通知,底层调用 addAdvisor
      addAdvice(interceptor);
      }
      • addAdvisor(pos, new DefaultPointcutAdvisor(advice)):Spring 中 Advice 对应的接口就是 Advisor,Spring 使用 Advisor 包装 Advice 实例
  • proxyFactory.copyFrom(this):填充一些信息到 proxyFactory

  • if (!proxyFactory.isProxyTargetClass()):条件成立说明 proxyTargetClass 为 false(默认),两种配置方法:

    • <aop:aspectj-autoproxy proxy-target-class="true"/> :强制使用 CGLIB
    • @EnableAspectJAutoProxy(proxyTargetClass = true)

    if (shouldProxyTargetClass(beanClass, beanName)):如果 bd 内有 preserveTargetClass = true ,那么这个 bd 对应的 class 创建代理时必须使用 CGLIB,条件成立设置 proxyTargetClass 为 true

    evaluateProxyInterfaces(beanClass, proxyFactory)根据目标类判定是否可以使用 JDK 动态代理

    • targetInterfaces = ClassUtils.getAllInterfacesForClass():获取当前目标对象 class 和父类的全部实现接口
    • boolean hasReasonableProxyInterface = false:实现的接口中是否有一个合理的接口
    • if (!isConfigurationCallbackInterface(ifc) && !isInternalLanguageInterface(ifc) && ifc.getMethods().length > 0):遍历所有的接口,如果有任意一个接口满足条件,设置 hRPI 变量为 true
      • 条件一:判断当前接口是否是 Spring 生命周期内会回调的接口
      • 条件二:接口不能是 GroovyObject、Factory、MockAccess 类型的
      • 条件三:找到一个可以使用的被代理的接口
    • if (hasReasonableProxyInterface)有合理的接口,将这些接口设置到 proxyFactory 内
    • proxyFactory.setProxyTargetClass(true)没有合理的代理接口,强制使用 CGLIB 创建对象
  • advisors = buildAdvisors(beanName, specificInterceptors):匹配目标对象 clazz 的 Advisors,填充至 ProxyFactory

  • proxyFactory.setPreFiltered(true):设置为 true 表示传递给 proxyFactory 的 Advisors 信息做过基础类和方法的匹配

  • return proxyFactory.getProxy(getProxyClassLoader()):创建代理对象

    1
    2
    3
    public Object getProxy() {
    return createAopProxy().getProxy();
    }

    DefaultAopProxyFactory.createAopProxy(AdvisedSupport config):参数是一个配置对象,保存着创建代理需要的生产资料,会加锁创建,保证线程安全

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    public AopProxy createAopProxy(AdvisedSupport config) throws AopConfigException {
    // 条件二为 true 代表强制使用 CGLIB 动态代理
    if (config.isOptimize() || config.isProxyTargetClass() ||
    // 条件三:被代理对象没有实现任何接口或者只实现了 SpringProxy 接口,只能使用 CGLIB 动态代理
    hasNoUserSuppliedProxyInterfaces(config)) {
    Class<?> targetClass = config.getTargetClass();
    if (targetClass == null) {
    throw new AopConfigException("");
    }
    // 条件成立说明 target 【是接口或者是已经被代理过的类型】,只能使用 JDK 动态代理
    if (targetClass.isInterface() || Proxy.isProxyClass(targetClass)) {
    return new JdkDynamicAopProxy(config); // 使用 JDK 动态代理
    }
    return new ObjenesisCglibAopProxy(config); // 使用 CGLIB 动态代理
    }
    else {
    return new JdkDynamicAopProxy(config); // 【有接口的情况下只能使用 JDK 动态代理】
    }
    }

    JdkDynamicAopProxy.getProxy(java.lang.ClassLoader):获取 JDK 的代理对象

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public JdkDynamicAopProxy(AdvisedSupport config) throws AopConfigException {
    // 配置类封装到 JdkDynamicAopProxy.advised 属性中
    this.advised = config;
    }
    public Object getProxy(@Nullable ClassLoader classLoader) {
    // 获取需要代理的接口数组
    Class<?>[] proxiedInterfaces = AopProxyUtils.completeProxiedInterfaces(this.advised, true);

    // 查找当前所有的需要代理的接口,看是否有 equals 方法和 hashcode 方法,如果有就做一个标记
    findDefinedEqualsAndHashCodeMethods(proxiedInterfaces);

    // 该方法最终返回一个代理类对象
    return Proxy.newProxyInstance(classLoader, proxiedInterfaces, this);
    // classLoader:类加载器 proxiedInterfaces:生成的代理类,需要实现的接口集合
    // this JdkDynamicAopProxy 实现了 InvocationHandler
    }

    AopProxyUtils.completeProxiedInterfaces(this.advised, true):获取代理的接口数组,并添加 SpringProxy 接口

    • specifiedInterfaces = advised.getProxiedInterfaces():从 ProxyFactory 中拿到所有的 target 提取出来的接口

      • if (specifiedInterfaces.length == 0):如果没有实现接口,检查当前 target 是不是接口或者已经是代理类,封装到 ProxyFactory 的 interfaces 集合中
    • addSpringProxy = !advised.isInterfaceProxied(SpringProxy.class):判断目标对象所有接口中是否有 SpringProxy 接口,没有的话需要添加,这个接口标识这个代理类型是 Spring 管理的

      • addAdvised = !advised.isOpaque() && !advised.isInterfaceProxied(Advised.class):判断目标对象的所有接口,是否已经有 Advised 接口
      • addDecoratingProxy = (decoratingProxy && !advised.isInterfaceProxied(DecoratingProxy.class)):判断目标对象的所有接口,是否已经有 DecoratingProxy 接口
      • int nonUserIfcCount = 0:非用户自定义的接口数量,接下来要添加上面的三个接口了
      • proxiedInterfaces = new Class<?>[specifiedInterfaces.length + nonUserIfcCount]:创建一个新的 class 数组,长度是原目标对象提取出来的接口数量和 Spring 追加的数量,然后进行 System.arraycopy 拷贝到新数组中
      • int index = specifiedInterfaces.length:获取原目标对象提取出来的接口数量,当作 index
      • if(addSpringProxy):根据上面三个布尔值把接口添加到新数组中
      • return proxiedInterfaces:返回追加后的接口集合

    JdkDynamicAopProxy.findDefinedEqualsAndHashCodeMethods():查找在任何定义在接口中的 equals 和 hashCode 方法

    • for (Class<?> proxiedInterface : proxiedInterfaces):遍历所有的接口
      • Method[] methods = proxiedInterface.getDeclaredMethods():获取接口中的所有方法

      • for (Method method : methods):遍历所有的方法

        • if (AopUtils.isEqualsMethod(method)):当前方法是 equals 方法,把 equalsDefined 置为 true
        • if (AopUtils.isHashCodeMethod(method)):当前方法是 hashCode 方法,把 hashCodeDefined 置为 true
      • if (this.equalsDefined && this.hashCodeDefined):如果有一个接口中有这两种方法,直接返回


方法增强

main() 函数中调用用户方法,会进入代理对象的 invoke 方法

JdkDynamicAopProxy 类中的 invoke 方法是真正执行代理方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// proxy:代理对象,method:目标对象的方法,args:目标对象方法对应的参数
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Object oldProxy = null;
boolean setProxyContext = false;

// advised 就是初始化 JdkDynamicAopProxy 对象时传入的变量
TargetSource targetSource = this.advised.targetSource;
Object target = null;

try {
// 条件成立说明代理类实现的接口没有定义 equals 方法,并且当前 method 调用 equals 方法,
// 就调用 JdkDynamicAopProxy 提供的 equals 方法
if (!this.equalsDefined && AopUtils.isEqualsMethod(method)) {
return equals(args[0]);
} //.....

Object retVal;
// 需不需要暴露当前代理对象到 AOP 上下文内
if (this.advised.exposeProxy) {
// 【把代理对象设置到上下文环境】
oldProxy = AopContext.setCurrentProxy(proxy);
setProxyContext = true;
}

// 根据 targetSource 获取真正的代理对象
target = targetSource.getTarget();
Class<?> targetClass = (target != null ? target.getClass() : null);

// 查找【适合该方法的增强】,首先从缓存中查找,查找不到进入主方法【下文详解】
List<Object> chain = this.advised.getInterceptorsAndDynamicInterceptionAdvice(method, targetClass);

// 拦截器链是空,说明当前 method 不需要被增强
if (chain.isEmpty()) {
Object[] argsToUse = AopProxyUtils.adaptArgumentsIfNecessary(method, args);
retVal = AopUtils.invokeJoinpointUsingReflection(target, method, argsToUse);
}
else {
// 有匹配当前 method 的方法拦截器,要做增强处理,把方法信息封装到方法调用器里
MethodInvocation invocation =
new ReflectiveMethodInvocation(proxy, target, method, args, targetClass, chain);
// 【拦截器链驱动方法,核心】
retVal = invocation.proceed();
}

Class<?> returnType = method.getReturnType();
if (retVal != null && retVal == target &&
returnType != Object.class && returnType.isInstance(proxy) &&
!RawTargetAccess.class.isAssignableFrom(method.getDeclaringClass())) {
// 如果目标方法返回目标对象,这里做个普通替换返回代理对象
retVal = proxy;
}

// 返回执行的结果
return retVal;
}
finally {
if (target != null && !targetSource.isStatic()) {
targetSource.releaseTarget(target);
}
// 如果允许了提前暴露,这里需要设置为初始状态
if (setProxyContext) {
// 当前代理对象已经完成工作,【把原始对象设置回上下文】
AopContext.setCurrentProxy(oldProxy);
}
}
}

this.advised.getInterceptorsAndDynamicInterceptionAdvice(method, targetClass):查找适合该方法的增强,首先从缓存中查找,获取通知时是从全部增强中获取适合当前类的,这里是从当前类的中获取适合当前方法的增强

  • AdvisorAdapterRegistry registry = GlobalAdvisorAdapterRegistry.getInstance():向容器注册适配器,可以将非 Advisor 类型的增强,包装成为 Advisor,将 Advisor 类型的增强提取出来对应的 MethodInterceptor

    • instance = new DefaultAdvisorAdapterRegistry():该对象向容器中注册了 MethodBeforeAdviceAdapter、AfterReturningAdviceAdapter、ThrowsAdviceAdapter 三个适配器

    • Advisor 中持有 Advice 对象

      1
      2
      3
      public interface Advisor {
      Advice getAdvice();
      }
  • advisors = config.getAdvisors():获取 ProxyFactory 内部持有的增强信息

  • interceptorList = new ArrayList<>(advisors.length):拦截器列表有 5 个,1 个 ExposeInvocation和 4 个增强器

  • actualClass = (targetClass != null ? targetClass : method.getDeclaringClass()):真实的目标对象类型

  • Boolean hasIntroductions = null:引介增强,不关心

  • for (Advisor advisor : advisors)遍历所有的 advisor 增强

  • if (advisor instanceof PointcutAdvisor):条件成立说明当前 Advisor 是包含切点信息的,进入匹配逻辑

    pointcutAdvisor = (PointcutAdvisor) advisor:转成可以获取到切点信息的接口

    if(config.isPreFiltered() || pointcutAdvisor.getPointcut().getClassFilter().matches(actualClass)):当前代理被预处理,或者当前被代理的 class 对象匹配当前 Advisor 成功,只是 class 匹配成功

    • mm = pointcutAdvisor.getPointcut().getMethodMatcher():获取切点的方法匹配器,不考虑引介增强

    • match = mm.matches(method, actualClass)静态匹配成功返回 true,只关注于处理类及其方法,不考虑参数

    • if (match):如果静态切点检查是匹配的,在运行的时候才进行动态切点检查,会考虑参数匹配(代表传入了参数)。如果静态匹配失败,直接不需要进行参数匹配,提高了工作效率

      interceptors = registry.getInterceptors(advisor):提取出当前 advisor 内持有的 advice 信息

      • Advice advice = advisor.getAdvice():获取增强方法

      • if (advice instanceof MethodInterceptor):当前 advice 是 MethodInterceptor 直接加入集合

      • for (AdvisorAdapter adapter : this.adapters)遍历三个适配器进行匹配(初始化时创建的),匹配成功创建对应的拦截器返回,以 MethodBeforeAdviceAdapter 为例

        if (adapter.supportsAdvice(advice)):判断当前 advice 是否是对应的 MethodBeforeAdvice

        interceptors.add(adapter.getInterceptor(advisor)):条件成立就往拦截器链中添加 advisor

        • advice = (MethodBeforeAdvice) advisor.getAdvice()获取增强方法
        • return new MethodBeforeAdviceInterceptor(advice)封装成 MethodBeforeAdviceInterceptor 返回

      interceptorList.add(new InterceptorAndDynamicMethodMatcher(interceptor, mm)):向拦截器链添加动态匹配器

      interceptorList.addAll(Arrays.asList(interceptors)):将当前 advisor 内部的方法拦截器追加到 interceptorList

  • interceptors = registry.getInterceptors(advisor):进入 else 的逻辑,说明当前 Advisor 匹配全部 class 的全部 method,全部加入到 interceptorList

  • return interceptorList:返回 method 方法的拦截器链

retVal = invocation.proceed():拦截器链驱动方法

  • if (this.currentInterceptorIndex == this.interceptorsAndDynamicMethodMatchers.size() - 1):条件成立说明方法拦截器全部都已经调用过了(index 从 - 1 开始累加),接下来需要执行目标对象的目标方法

    return invokeJoinpoint()调用连接点(目标)方法

  • this.interceptorsAndDynamicMethodMatchers.get(++this.currentInterceptorIndex)获取下一个方法拦截器

  • if (interceptorOrInterceptionAdvice instanceof InterceptorAndDynamicMethodMatcher):需要运行时匹配

    if (dm.methodMatcher.matches(this.method, targetClass, this.arguments)):判断是否匹配成功

    • return dm.interceptor.invoke(this):匹配成功,执行方法
    • return proceed():匹配失败跳过当前拦截器
  • return ((MethodInterceptor) interceptorOrInterceptionAdvice).invoke(this)一般方法拦截器都会执行到该方法,此方法内继续执行 proceed() 完成责任链的驱动,直到最后一个 MethodBeforeAdviceInterceptor 调用前置通知,然后调用 mi.proceed(),发现是最后一个拦截器就直接执行连接点(目标方法),return 到上一个拦截器的 mi.proceed() 处,依次返回到责任链的上一个拦截器执行通知方法

图示先从上往下建立链,然后从下往上依次执行,责任链模式

  • 正常执行:(环绕通知)→ 前置通知 → 目标方法 → 后置通知 → 返回通知

  • 出现异常:(环绕通知)→ 前置通知 → 目标方法 → 后置通知 → 异常通知

  • MethodBeforeAdviceInterceptor 源码:

    1
    2
    3
    4
    5
    6
    public Object invoke(MethodInvocation mi) throws Throwable {
    // 先执行通知方法,再驱动责任链
    this.advice.before(mi.getMethod(), mi.getArguments(), mi.getThis());
    // 开始驱动目标方法执行,执行完后返回到这,然后继续向上层返回
    return mi.proceed();
    }

    AfterReturningAdviceInterceptor 源码:没有任何异常处理机制,直接抛给上层

    1
    2
    3
    4
    5
    6
    public Object invoke(MethodInvocation mi) throws Throwable {
    // 先驱动责任链,再执行通知方法
    Object retVal = mi.proceed();
    this.advice.afterReturning(retVal, mi.getMethod(), mi.getArguments(), mi.getThis());
    return retVal;
    }

    AspectJAfterThrowingAdvice 执行异常处理:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public Object invoke(MethodInvocation mi) throws Throwable {
    try {
    // 默认直接驱动责任链
    return mi.proceed();
    }
    catch (Throwable ex) {
    // 出现错误才执行该方法
    if (shouldInvokeOnThrowing(ex)) {
    invokeAdviceMethod(getJoinPointMatch(), null, ex);
    }
    throw ex;
    }
    }

参考视频:https://www.bilibili.com/video/BV1gW411W7wy


事务

解析方法

标签解析
1
<tx:annotation-driven transaction-manager="txManager"/>

容器启动时会根据注解注册对应的解析器:

1
2
3
4
5
6
7
8
9
10
11
public class TxNamespaceHandler extends NamespaceHandlerSupport {
public void init() {
registerBeanDefinitionParser("advice", new TxAdviceBeanDefinitionParser());
// 注册解析器
registerBeanDefinitionParser("annotation-driven", new AnnotationDrivenBeanDefinitionParser());
registerBeanDefinitionParser("jta-transaction-manager", new JtaTransactionManagerBeanDefinitionParser());
}
}
protected final void registerBeanDefinitionParser(String elementName, BeanDefinitionParser parser) {
this.parsers.put(elementName, parser);
}

获取对应的解析器 NamespaceHandlerSupport#findParserForElement:

1
2
3
4
5
6
7
private BeanDefinitionParser findParserForElement(Element element, ParserContext parserContext) {
String localName = parserContext.getDelegate().getLocalName(element);
// 获取对应的解析器
BeanDefinitionParser parser = this.parsers.get(localName);
// ...
return parser;
}

调用解析器的方法对 XML 文件进行解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public BeanDefinition parse(Element element, ParserContext parserContext) {
// 向Spring容器注册了一个 BD -> TransactionalEventListenerFactory.class
registerTransactionalEventListenerFactory(parserContext);
String mode = element.getAttribute("mode");
if ("aspectj".equals(mode)) {
// mode="aspectj"
registerTransactionAspect(element, parserContext);
if (ClassUtils.isPresent("javax.transaction.Transactional", getClass().getClassLoader())) {
registerJtaTransactionAspect(element, parserContext);
}
}
else {
// mode="proxy",默认逻辑,不配置 mode 时
// 用来向容器中注入一些 BeanDefinition,包括事务增强器、事务拦截器、注解解析器
AopAutoProxyConfigurer.configureAutoProxyCreator(element, parserContext);
}
return null;
}

注解解析

@EnableTransactionManagement 导入 TransactionManagementConfigurationSelector,该类给 Spring 容器中两个组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
protected String[] selectImports(AdviceMode adviceMode) {
switch (adviceMode) {
// 导入 AutoProxyRegistrar 和 ProxyTransactionManagementConfiguration(默认)
case PROXY:
return new String[] {AutoProxyRegistrar.class.getName(),
ProxyTransactionManagementConfiguration.class.getName()};
// 导入 AspectJTransactionManagementConfiguration(与声明式事务无关)
case ASPECTJ:
return new String[] {determineTransactionAspectClass()};
default:
return null;
}
}

AutoProxyRegistrar:给容器中注册 InfrastructureAdvisorAutoProxyCreator,利用后置处理器机制拦截 bean 以后包装并返回一个代理对象,代理对象中保存所有的拦截器,利用拦截器的链式机制依次进入每一个拦截器中进行拦截执行(就是 AOP 原理)

ProxyTransactionManagementConfiguration:是一个 Spring 的事务配置类,注册了三个 Bean:

  • BeanFactoryTransactionAttributeSourceAdvisor:事务驱动,利用注解 @Bean 把该类注入到容器中,该增强器有两个字段:
  • TransactionAttributeSource:解析事务注解的相关信息,真实类型是 AnnotationTransactionAttributeSource,构造方法中注册了三个注解解析器,解析 Spring、JTA、Ejb3 三种类型的事务注解
  • TransactionInterceptor:事务拦截器,代理对象执行拦截器方法时,调用 TransactionInterceptor 的 invoke 方法,底层调用TransactionAspectSupport.invokeWithinTransaction(),通过 PlatformTransactionManager 控制着事务的提交和回滚,所以事务的底层原理就是通过 AOP 动态织入,进行事务开启和提交

注解解析器 SpringTransactionAnnotationParser 解析 @Transactional 注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
protected TransactionAttribute parseTransactionAnnotation(AnnotationAttributes attributes) {
RuleBasedTransactionAttribute rbta = new RuleBasedTransactionAttribute();
// 从注解信息中获取传播行为
Propagation propagation = attributes.getEnum("propagation");
rbta.setPropagationBehavior(propagation.value());
// 获取隔离界别
Isolation isolation = attributes.getEnum("isolation");
rbta.setIsolationLevel(isolation.value());
rbta.setTimeout(attributes.getNumber("timeout").intValue());
// 从注解信息中获取 readOnly 参数
rbta.setReadOnly(attributes.getBoolean("readOnly"));
// 从注解信息中获取 value 信息并且设置 qualifier,表示当前事务指定使用的【事务管理器】
rbta.setQualifier(attributes.getString("value"));
// 【存放的是 rollback 条件】,回滚规则放在这个集合
List<RollbackRuleAttribute> rollbackRules = new ArrayList<>();
// 表示事务碰到哪些指定的异常才进行回滚,不指定的话默认是 RuntimeException/Error 非检查型异常菜回滚
for (Class<?> rbRule : attributes.getClassArray("rollbackFor")) {
rollbackRules.add(new RollbackRuleAttribute(rbRule));
}
// 与 rollbackFor 功能相同
for (String rbRule : attributes.getStringArray("rollbackForClassName")) {
rollbackRules.add(new RollbackRuleAttribute(rbRule));
}
// 表示事务碰到指定的 exception 实现对象不进行回滚,否则碰到其他的class就进行回滚
for (Class<?> rbRule : attributes.getClassArray("noRollbackFor")) {
rollbackRules.add(new NoRollbackRuleAttribute(rbRule));
}
for (String rbRule : attributes.getStringArray("noRollbackForClassName")) {
rollbackRules.add(new NoRollbackRuleAttribute(rbRule));
}
// 设置回滚规则
rbta.setRollbackRules(rollbackRules);

return rbta;
}

驱动方法

TransactionInterceptor 事务拦截器的核心驱动方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public Object invoke(MethodInvocation invocation) throws Throwable {
// targetClass 是需要被事务增强器增强的目标类,invocation.getThis() → 目标对象 → 目标类
Class<?> targetClass = (invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null);
// 参数一是目标方法,参数二是目标类,参数三是方法引用,用来触发驱动方法
return invokeWithinTransaction(invocation.getMethod(), targetClass, invocation::proceed);
}

protected Object invokeWithinTransaction(Method method, @Nullable Class<?> targetClass,
final InvocationCallback invocation) throws Throwable {

// 事务属性源信息
TransactionAttributeSource tas = getTransactionAttributeSource();
// 提取 @Transactional 注解信息,txAttr 是注解信息的承载对象
final TransactionAttribute txAttr = (tas != null ? tas.getTransactionAttribute(method, targetClass) : null);
// 获取 Spring 配置的事务管理器
// 首先会检查是否通过XML或注解配置 qualifier,没有就尝试去容器获取,一般情况下为 DatasourceTransactionManager
final PlatformTransactionManager tm = determineTransactionManager(txAttr);
// 权限定类名.方法名,该值用来当做事务名称使用
final String joinpointIdentification = methodIdentification(method, targetClass, txAttr);

// 条件成立说明是【声明式事务】
if (txAttr == null || !(tm instanceof CallbackPreferringPlatformTransactionManager)) {
// 用来【开启事务】
TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, joinpointIdentification);

Object retVal;
try {
// This is an 【around advice】: Invoke the next interceptor in the chain.
// 环绕通知,执行目标方法(方法引用方式,invocation::proceed,还是调用 proceed)
retVal = invocation.proceedWithInvocation();
}
catch (Throwable ex) {
// 执行业务代码时抛出异常,执行回滚逻辑
completeTransactionAfterThrowing(txInfo, ex);
throw ex;
}
finally {
// 清理事务的信息
cleanupTransactionInfo(txInfo);
}
// 提交事务的入口
commitTransactionAfterReturning(txInfo);
return retVal;
}
else {
// 编程式事务,省略
}
}

开启事务

事务绑定

创建事务的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
protected TransactionInfo createTransactionIfNecessary(@Nullable PlatformTransactionManager tm,
@Nullable TransactionAttribute txAttr,
final String joinpointIdentification) {

// If no name specified, apply method identification as transaction name.
if (txAttr != null && txAttr.getName() == null) {
// 事务的名称: 类的权限定名.方法名
txAttr = new DelegatingTransactionAttribute(txAttr) {
@Override
public String getName() {
return joinpointIdentification;
}
};
}
TransactionStatus status = null;
if (txAttr != null) {
if (tm != null) {
// 通过事务管理器根据事务属性创建事务状态对象,事务状态对象一般情况下包装着 事务对象,当然也有可能是null
// 方法上的注解为 @Transactional(propagation = NOT_SUPPORTED || propagation = NEVER) 时
// 【下一小节详解】
status = tm.getTransaction(txAttr);
}
else {
if (logger.isDebugEnabled()) {
logger.debug("Skipping transactional joinpoint [" + joinpointIdentification +
"] because no transaction manager has been configured");
}
}
}
// 包装成一个上层的事务上下文对象
return prepareTransactionInfo(tm, txAttr, joinpointIdentification, status);
}

TransactionAspectSupport#prepareTransactionInfo:为事务的属性和状态准备一个事务信息对象

  • TransactionInfo txInfo = new TransactionInfo(tm, txAttr, joinpointIdentification):创建事务信息对象
  • txInfo.newTransactionStatus(status):填充事务的状态信息
  • txInfo.bindToThread():利用 ThreadLocal 把当前事务信息绑定到当前线程,不同的事务信息会形成一个栈的结构
    • this.oldTransactionInfo = transactionInfoHolder.get():获取其他事务的信息存入 oldTransactionInfo
    • transactionInfoHolder.set(this):将当前的事务信息设置到 ThreadLocalMap 中

事务创建
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public final TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException {
// 获取事务的对象
Object transaction = doGetTransaction();
boolean debugEnabled = logger.isDebugEnabled();

if (definition == null) {
// Use defaults if no transaction definition given.
definition = new DefaultTransactionDefinition();
}
// 条件成立说明当前是事务重入的情况,事务中有 ConnectionHolder 对象
if (isExistingTransaction(transaction)) {
// a方法开启事务,a方法内调用b方法,b方法仍然加了 @Transactional 注解,需要检查传播行为
return handleExistingTransaction(definition, transaction, debugEnabled);
}

// 逻辑到这说明当前线程没有连接资源,一个连接对应一个事务,没有连接就相当于没有开启事务
// 检查事务的延迟属性
if (definition.getTimeout() < TransactionDefinition.TIMEOUT_DEFAULT) {
throw new InvalidTimeoutException("Invalid transaction timeout", definition.getTimeout());
}

// 传播行为是 MANDATORY,没有事务就抛出异常
if (definition.getPropagationBehavior() == TransactionDefinition.PROPAGATION_MANDATORY) {
throw new IllegalTransactionStateException();
}
// 需要开启事务的传播行为
else if (definition.getPropagationBehavior() == TransactionDefinition.PROPAGATION_REQUIRED ||
definition.getPropagationBehavior() == TransactionDefinition.PROPAGATION_REQUIRES_NEW ||
definition.getPropagationBehavior() == TransactionDefinition.PROPAGATION_NESTED) {
// 什么也没挂起,因为线程并没有绑定事务
SuspendedResourcesHolder suspendedResources = suspend(null);
try {
// 是否支持同步线程事务,一般是 true
boolean newSynchronization = (getTransactionSynchronization() != SYNCHRONIZATION_NEVER);
// 新建一个事务状态信息
DefaultTransactionStatus status = newTransactionStatus(
definition, transaction, true, newSynchronization, debugEnabled, suspendedResources);
// 【启动事务】
doBegin(transaction, definition);
// 设置线程上下文变量,方便程序运行期间获取当前事务的一些核心的属性,initSynchronization() 启动同步
prepareSynchronization(status, definition);
return status;
}
catch (RuntimeException | Error ex) {
// 恢复现场
resume(null, suspendedResources);
throw ex;
}
}
// 不支持事务的传播行为
else {
// Create "empty" transaction: no actual transaction, but potentially synchronization.
boolean newSynchronization = (getTransactionSynchronization() == SYNCHRONIZATION_ALWAYS);
// 创建事务状态对象
// 参数2 transaction 是 null 说明当前事务状态是未手动开启事,线程上未绑定任何的连接资源,业务程序执行时需要先去 datasource 获取的 conn,是自动提交事务的,不需要 Spring 再提交事务
// 参数6 suspendedResources 是 null 说明当前事务状态未挂起任何事务,当前事务执行到后置处理时不需要恢复现场
return prepareTransactionStatus(definition, null, true, newSynchronization, debugEnabled, null);
}
}

DataSourceTransactionManager#doGetTransaction:真正获取事务的方法

  • DataSourceTransactionObject txObject = new DataSourceTransactionObject()创建事务对象

  • txObject.setSavepointAllowed(isNestedAllowed()):设置事务对象是否支持保存点,由事务管理器控制(默认不支持)

  • ConnectionHolder conHolder = TransactionSynchronizationManager.getResource(obtainDataSource())

    • 从 ThreadLocal 中获取 conHolder 资源,可能拿到 null 或者不是 null

    • 是 null:举例

      1
      2
      @Transaction
      public void a() {...b.b()....}
    • 不是 null:执行 b 方法事务增强的前置逻辑时,可以拿到 a 放进去的 conHolder 资源

      1
      2
      @Transaction
      public void b() {....}
  • txObject.setConnectionHolder(conHolder, false):将 ConnectionHolder 保存到事务对象内,参数二是 false 代表连接资源是上层事务共享的,不是新建的连接资源

  • return txObject:返回事务的对象

DataSourceTransactionManager#doBegin:事务开启的逻辑

  • txObject = (DataSourceTransactionObject) transaction:强转为事务对象

  • 事务中没有数据库连接资源就要分配:

    Connection newCon = obtainDataSource().getConnection()获取 JDBC 原生的数据库连接对象

    txObject.setConnectionHolder(new ConnectionHolder(newCon), true):代表是新开启的事务,新建的连接对象

  • previousIsolationLevel = DataSourceUtils.prepareConnectionForTransaction(con, definition):修改连接属性

    • if (definition != null && definition.isReadOnly()):注解(或 XML)配置了只读属性,需要设置

    • if (..definition.getIsolationLevel() != TransactionDefinition.ISOLATION_DEFAULT):注解配置了隔离级别

      int currentIsolation = con.getTransactionIsolation():获取连接的隔离界别

      previousIsolationLevel = currentIsolation:保存之前的隔离界别,返回该值

      con.setTransactionIsolation(definition.getIsolationLevel())将当前连接设置为配置的隔离界别

  • txObject.setPreviousIsolationLevel(previousIsolationLevel):将 Conn 原来的隔离级别保存到事务对象,为了释放 Conn 时重置回原状态

  • if (con.getAutoCommit()):默认会成立,说明还没开启事务

    txObject.setMustRestoreAutoCommit(true):保存 Conn 原来的事务状态

    con.setAutoCommit(false)开启事务,JDBC 原生的方式

  • txObject.getConnectionHolder().setTransactionActive(true):表示 Holder 持有的 Conn 已经手动开启事务了

  • TransactionSynchronizationManager.bindResource(obtainDataSource(), txObject.getConnectionHolder()):将 ConnectionHolder 对象绑定到 ThreadLocal 内,数据源为 key,为了方便获取手动开启事务的连接对象去执行 SQL


事务重入

事务重入的核心处理逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
private TransactionStatus handleExistingTransaction( TransactionDefinition definition, 
Object transaction, boolean debugEnabled){
// 传播行为是 PROPAGATION_NEVER,需要以非事务方式执行操作,如果当前事务存在则【抛出异常】
if (definition.getPropagationBehavior() == TransactionDefinition.PROPAGATION_NEVER) {
throw new IllegalTransactionStateException();
}
// 传播行为是 PROPAGATION_NOT_SUPPORTED,以非事务方式运行,如果当前存在事务,则【把当前事务挂起】
if (definition.getPropagationBehavior() == TransactionDefinition.PROPAGATION_NOT_SUPPORTED) {
// 挂起事务
Object suspendedResources = suspend(transaction);
boolean newSynchronization = (getTransactionSynchronization() == SYNCHRONIZATION_ALWAYS);
// 创建一个非事务的事务状态对象返回
return prepareTransactionStatus(definition, null, false, newSynchronization, debugEnabled, suspendedResources);
}
// 开启新事物的逻辑
if (definition.getPropagationBehavior() == TransactionDefinition.PROPAGATION_REQUIRES_NEW) {
// 【挂起当前事务】
SuspendedResourcesHolder suspendedResources = suspend(transaction);
// 【开启新事物】
}
// 传播行为是 PROPAGATION_NESTED,嵌套事务
if (definition.getPropagationBehavior() == TransactionDefinition.PROPAGATION_NESTED) {
// Spring 默认不支持内嵌事务
// 【开启方式】:<property name="nestedTransactionAllowed" value="true">
if (!isNestedTransactionAllowed()) {
throw new NestedTransactionNotSupportedException();
}

if (useSavepointForNestedTransaction()) {
// 为当前方法创建一个 TransactionStatus 对象,
DefaultTransactionStatus status =
prepareTransactionStatus(definition, transaction, false, false, debugEnabled, null);
// 创建一个 JDBC 的保存点
status.createAndHoldSavepoint();
// 不需要使用同步,直接返回
return status;
}
else {
// Usually only for JTA transaction,开启一个新事务
}
}

// Assumably PROPAGATION_SUPPORTS or PROPAGATION_REQUIRED,【使用当前的事务】
boolean newSynchronization = (getTransactionSynchronization() != SYNCHRONIZATION_NEVER);
return prepareTransactionStatus(definition, transaction, false, newSynchronization, debugEnabled, null);
}

挂起恢复

AbstractPlatformTransactionManager#suspend:挂起事务,并获得一个上下文信息对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
protected final SuspendedResourcesHolder suspend(@Nullable Object transaction) {
// 事务是同步状态的
if (TransactionSynchronizationManager.isSynchronizationActive()) {
List<TransactionSynchronization> suspendedSynchronizations = doSuspendSynchronization();
try {
Object suspendedResources = null;
if (transaction != null) {
// do it
suspendedResources = doSuspend(transaction);
}
//将上层事务绑定在线程上下文的变量全部取出来
//...
// 通过被挂起的资源和上层事务的上下文变量,创建一个【SuspendedResourcesHolder】返回
return new SuspendedResourcesHolder(suspendedResources, suspendedSynchronizations,
name, readOnly, isolationLevel, wasActive);
} //...
}
protected Object doSuspend(Object transaction) {
DataSourceTransactionObject txObject = (DataSourceTransactionObject) transaction;
// 将当前方法的事务对象 connectionHolder 属性置为 null,不和上层共享资源
// 当前方法有可能是不开启事务或者要开启一个独立的事务
txObject.setConnectionHolder(null);
// 【解绑在线程上的事务】
return TransactionSynchronizationManager.unbindResource(obtainDataSource());
}

AbstractPlatformTransactionManager#resume:恢复现场,根据挂起资源去恢复线程上下文信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
protected final void resume(Object transaction, SuspendedResourcesHolder resourcesHolder) {
if (resourcesHolder != null) {
// 获取被挂起的事务资源
Object suspendedResources = resourcesHolder.suspendedResources;
if (suspendedResources != null) {
//绑定上一个事务的 ConnectionHolder 到线程上下文
doResume(transaction, suspendedResources);
}
List<TransactionSynchronization> suspendedSynchronizations = resourcesHolder.suspendedSynchronizations;
if (suspendedSynchronizations != null) {
//....
// 将线程上下文变量恢复为上一个事务的挂起现场
doResumeSynchronization(suspendedSynchronizations);
}
}
}
protected void doResume(@Nullable Object transaction, Object suspendedResources) {
// doSuspend 的逆动作,【绑定资源】
TransactionSynchronizationManager.bindResource(obtainDataSource(), suspendedResources);
}

提交回滚

回滚方式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
protected void completeTransactionAfterThrowing(@Nullable TransactionInfo txInfo, Throwable ex) {
// 事务状态信息不为空进入逻辑
if (txInfo != null && txInfo.getTransactionStatus() != null) {
// 条件二成立 说明目标方法抛出的异常需要回滚事务
if (txInfo.transactionAttribute != null && txInfo.transactionAttribute.rollbackOn(ex)) {
try {
// 事务管理器的回滚方法
txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus());
}
catch (TransactionSystemException ex2) {}
}
else {
// 执行到这里,说明当前事务虽然抛出了异常,但是该异常并不会导致整个事务回滚
try {
// 提交事务
txInfo.getTransactionManager().commit(txInfo.getTransactionStatus());
}
catch (TransactionSystemException ex2) {}
}
}
}
public boolean rollbackOn(Throwable ex) {
// 继承自 RuntimeException 或 error 的是【非检查型异常】,才会归滚事务
// 如果配置了其他回滚错误,会获取到回滚规则 rollbackRules 进行判断
return (ex instanceof RuntimeException || ex instanceof Error);
}
1
2
3
4
5
6
7
8
9
public final void rollback(TransactionStatus status) throws TransactionException {
// 事务已经完成不需要回滚
if (status.isCompleted()) {
throw new IllegalTransactionStateException();
}
DefaultTransactionStatus defStatus = (DefaultTransactionStatus) status;
// 开始回滚事务
processRollback(defStatus, false);
}

AbstractPlatformTransactionManager#processRollback:事务回滚

  • triggerBeforeCompletion(status):用来做扩展逻辑,回滚前的前置处理

  • if (status.hasSavepoint()):条件成立说明当前事务是一个内嵌事务,当前方法只是复用了上层事务的一个内嵌事务

    status.rollbackToHeldSavepoint():内嵌事务加入事务时会创建一个保存点,此时恢复至保存点

  • if (status.isNewTransaction()):说明事务是当前连接开启的,需要去回滚事务

    doRollback(status):真正的的回滚函数

    • DataSourceTransactionObject txObject = status.getTransaction():获取事务对象
    • Connection con = txObject.getConnectionHolder().getConnection():获取连接对象
    • con.rollback()JDBC 的方式回滚事务
  • else:当前方法是共享的上层的事务,和上层使用同一个 Conn 资源,共享的事务不能直接回滚,应该交给上层处理

    doSetRollbackOnly(status):设置 con.rollbackOnly = true,线程回到上层事务 commit 时会检查该字段,然后执行回滚操作

  • triggerAfterCompletion(status, TransactionSynchronization.STATUS_ROLLED_BACK):回滚的后置处理

  • cleanupAfterCompletion(status):清理和恢复现场


提交方式
1
2
3
4
5
6
protected void commitTransactionAfterReturning(@Nullable TransactionInfo txInfo) {
if (txInfo != null && txInfo.getTransactionStatus() != null) {
// 事务管理器的提交方法
txInfo.getTransactionManager().commit(txInfo.getTransactionStatus());
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public final void commit(TransactionStatus status) throws TransactionException {
// 已经完成的事务不需要提交了
if (status.isCompleted()) {
throw new IllegalTransactionStateException();
}
DefaultTransactionStatus defStatus = (DefaultTransactionStatus) status;
// 条件成立说明是当前的业务强制回滚
if (defStatus.isLocalRollbackOnly()) {
// 回滚逻辑,
processRollback(defStatus, false);
return;
}
// 成立说明共享当前事务的【下层事务逻辑出错,需要回滚】
if (!shouldCommitOnGlobalRollbackOnly() && defStatus.isGlobalRollbackOnly()) {
// 如果当前事务还是事务重入,会继续抛给上层,最上层事务会进行真实的事务回滚操作
processRollback(defStatus, true);
return;
}
// 执行提交
processCommit(defStatus);
}

AbstractPlatformTransactionManager#processCommit:事务提交

  • prepareForCommit(status):前置处理

  • if (status.hasSavepoint()):条件成立说明当前事务是一个内嵌事务,只是复用了上层事务

    status.releaseHeldSavepoint():清理保存点,因为没有发生任何异常,所以保存点没有存在的意义了

  • if (status.isNewTransaction()):说明事务是归属于当前连接的,需要去提交事务

    doCommit(status):真正的提交函数

    • Connection con = txObject.getConnectionHolder().getConnection():获取连接对象
    • con.commit()JDBC 的方式提交事务
  • doRollbackOnCommitException(status, ex)提交事务出错后进行回滚

  • cleanupAfterCompletion(status):清理和恢复现场


清理现场

恢复上层事务:

1
2
3
4
5
6
7
8
9
10
protected void cleanupTransactionInfo(@Nullable TransactionInfo txInfo) {
if (txInfo != null) {
// 从当前线程的 ThreadLocal 获取上层的事务信息,将当前事务出栈,继续执行上层事务
txInfo.restoreThreadLocalStatus();
}
}
private void restoreThreadLocalStatus() {
// Use stack to restore old transaction TransactionInfo.
transactionInfoHolder.set(this.oldTransactionInfo);
}

当前层级事务结束时的清理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private void cleanupAfterCompletion(DefaultTransactionStatus status) {
// 设置当前方法的事务状态为完成状态
status.setCompleted();
if (status.isNewSynchronization()) {
// 清理线程上下文变量以及扩展点注册的 sync
TransactionSynchronizationManager.clear();
}
// 事务是当前线程开启的
if (status.isNewTransaction()) {
// 解绑资源
doCleanupAfterCompletion(status.getTransaction());
}
// 条件成立说明当前事务执行的时候,【挂起了一个上层的事务】
if (status.getSuspendedResources() != null) {
Object transaction = (status.hasTransaction() ? status.getTransaction() : null);
// 恢复上层事务现场
resume(transaction, (SuspendedResourcesHolder) status.getSuspendedResources());
}
}

DataSourceTransactionManager#doCleanupAfterCompletion:清理工作

  • TransactionSynchronizationManager.unbindResource(obtainDataSource()):解绑数据库资源

  • if (txObject.isMustRestoreAutoCommit()):是否恢复连接,Conn 归还到 DataSource,归还前需要恢复到申请时的状态

    con.setAutoCommit(true):恢复连接为自动提交

  • DataSourceUtils.resetConnectionAfterTransaction(con, txObject.getPreviousIsolationLevel()):恢复隔离级别

  • DataSourceUtils.releaseConnection(con, this.dataSource)将连接归还给数据库连接池

  • txObject.getConnectionHolder().clear():清理 ConnectionHolder 资源


注解

Component

@Component 解析流程:

  • 注解类启动容器的时,注册 ClassPathBeanDefinitionScanner 到容器,用来扫描 Bean 的相关信息

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    protected Set<BeanDefinitionHolder> doScan(String... basePackages) {
    Set<BeanDefinitionHolder> beanDefinitions = new LinkedHashSet<>();
    // 遍历指定的所有的包,【这就相当于扫描了】
    for (String basePackage : basePackages) {
    // 读取当前包下的资源装换为 BeanDefinition,字节流的方式
    Set<BeanDefinition> candidates = findCandidateComponents(basePackage);
    for (BeanDefinition candidate : candidates) {
    // 遍历,封装,类似于 XML 的解析方式,注册到容器中
    registerBeanDefinition(definitionHolder, this.registry)
    }
    return beanDefinitions;
    }
  • ClassPathScanningCandidateComponentProvider.findCandidateComponents()

    1
    2
    3
    4
    5
    6
    7
    8
    public Set<BeanDefinition> findCandidateComponents(String basePackage) {
    if (this.componentsIndex != null && indexSupportsIncludeFilters()) {
    return addCandidateComponentsFromIndex(this.componentsIndex, basePackage);
    }
    else {
    return scanCandidateComponents(basePackage);
    }
    }
    1
    private Set<BeanDefinition> scanCandidateComponents(String basePackage) {}
    • String packageSearchPath = ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX resolveBasePackage(basePackage) + '/' + this.resourcePattern :将 package 转化为 ClassLoader 类资源搜索路径 packageSearchPath,例如:com.sea.spring.boot 转化为 classpath*:com/sea/spring/boot/**/*.class

    • resources = getResourcePatternResolver().getResources(packageSearchPath):加载路径下的资源

    • for (Resource resource : resources) :遍历所有的资源

      metadataReader = getMetadataReaderFactory().getMetadataReader(resource):获取元数据阅读器

      if (isCandidateComponent(metadataReader))当前类不匹配任何排除过滤器,并且匹配一个包含过滤器,返回 true

      • includeFilters 由 registerDefaultFilters() 设置初始值,方法有 @Component,没有 @Service,因为 @Component 是 @Service 的元注解,Spring 在读取 @Service 时也读取了元注解,并将 @Service 作为 @Component 处理

        1
        this.includeFilters.add(new AnnotationTypeFilter(Component.class))
        1
        2
        3
        4
        5
        @Target({ElementType.TYPE})
        @Retention(RetentionPolicy.RUNTIME)
        @Documented
        @Component // 拥有了 Component 功能
        public @interface Service {}

      candidates.add(sbd):添加到返回结果的 list

参考文章:https://my.oschina.net/floor/blog/4325651


Autowired

打开 @Autowired 源码,注释上写 Please consult the javadoc for the AutowiredAnnotationBeanPostProcessor

AutowiredAnnotationBeanPostProcessor 间接实现 InstantiationAwareBeanPostProcessor,就具备了实例化前后(而不是初始化前后)管理对象的能力,实现了 BeanPostProcessor,具有初始化前后管理对象的能力,实现 BeanFactoryAware,具备随时拿到 BeanFactory 的能力,所以这个类具备一切后置处理器的能力

在容器启动,为对象赋值的时候,遇到 @Autowired 注解,会用后置处理器机制,来创建属性的实例,然后再利用反射机制,将实例化好的属性,赋值给对象上,这就是 Autowired 的原理

作用时机:

  • Spring 在每个 Bean 实例化之后,调用 AutowiredAnnotationBeanPostProcessor 的 postProcessMergedBeanDefinition() 方法,查找该 Bean 是否有 @Autowired 注解,进行相关元数据的获取
  • Spring 在每个 Bean 调用 populateBean() 进行属性注入的时候,即调用 postProcessProperties() 方法,查找该 Bean 属性是否有 @Autowired 注解,进行相关数据的填充

MVC

基本介绍

SpringMVC:是一种基于 Java 实现 MVC 模型的轻量级 Web 框架

SpringMVC 优点:

  • 使用简单
  • 性能突出(对比现有的框架技术)
  • 灵活性强

软件开发三层架构:

  • 表现层:负责数据展示

  • 业务层:负责业务处理

  • 数据层:负责数据操作

MVC(Model View Controller),一种用于设计创建Web应用程序表现层的模式

  • Model(模型):数据模型,用于封装数据

  • View(视图):页面视图,用于展示数据

    • jsp
    • html
  • Controller(控制器):处理用户交互的调度器,用于根据用户需求处理程序逻辑

    • Servlet
    • SpringMVC

参考视频:https://space.bilibili.com/37974444/


基本配置

入门项目

流程分析:

  • 服务器启动
    1. 加载 web.xml 中 DispatcherServlet
    2. 读取 spring-mvc.xml 中的配置,加载所有 controller 包中所有标记为 bean 的类
    3. 读取 bean 中方法上方标注 @RequestMapping 的内容
  • 处理请求
    1. DispatcherServlet 配置拦截所有请求 /
    2. 使用请求路径与所有加载的 @RequestMapping 的内容进行比对
    3. 执行对应的方法
    4. 根据方法的返回值在 webapp 目录中查找对应的页面并展示

代码实现:

  • pom.xml 导入坐标

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    <modelVersion>4.0.0</modelVersion>

    <groupId>demo</groupId>
    <artifactId>spring_base_config</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>war</packaging>

    <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
    </properties>

    <dependencies>
    <!-- servlet3.0规范的坐标 -->
    <dependency>
    <groupId>javax.servlet</groupId>
    <artifactId>javax.servlet-api</artifactId>
    <version>3.1.0</version>
    <scope>provided</scope>
    </dependency>
    <!--jsp坐标-->
    <dependency>
    <groupId>javax.servlet.jsp</groupId>
    <artifactId>jsp-api</artifactId>
    <version>2.1</version>
    <scope>provided</scope>
    </dependency>
    <!--spring的坐标-->
    <dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context</artifactId>
    <version>5.1.9.RELEASE</version>
    </dependency>
    <!--springmvc的坐标-->
    <dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-webmvc</artifactId>
    <version>5.1.9.RELEASE</version>
    </dependency>
    </dependencies>

    <!--构建-->
    <build>
    <!--设置插件-->
    <plugins>
    <!--具体的插件配置-->
    <plugin>
    <groupId>org.apache.tomcat.maven</groupId>
    <artifactId>tomcat7-maven-plugin</artifactId>
    <version>2.1</version>
    <configuration>
    <port>80</port>
    <path>/</path>
    </configuration>
    </plugin>
    </plugins>
    </build>
  • 设定具体 Controller,控制层 java / controller / UserController

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @Controller  //@Component衍生注解
    public class UserController {
    //设定当前方法的访问映射地址,等同于Servlet在web.xml中的配置
    @RequestMapping("/save")
    //设置当前方法返回值类型为String,用于指定请求完成后跳转的页面
    public String save(){
    System.out.println("user mvc controller is running ...");
    //设定具体跳转的页面
    return "success.jsp";
    }
    }
  • webapp / WEB-INF / web.xml,配置SpringMVC核心控制器,请求转发到对应的具体业务处理器Controller中(等同于Servlet配置)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    <?xml version="1.0" encoding="UTF-8"?>
    <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
    version="3.1">
    <!--配置Servlet-->
    <servlet>
    <servlet-name>DispatcherServlet</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <!--加载Spring控制文件-->
    <init-param>
    <param-name>contextConfigLocation</param-name>
    <param-value>classpath*:spring-mvc.xml</param-value>
    </init-param>
    </servlet>
    <servlet-mapping>
    <servlet-name>DispatcherServlet</servlet-name>
    <url-pattern>/</url-pattern>
    </servlet-mapping>
    </web-app>
  • resouces / spring-mvc.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    <?xml version="1.0" encoding="UTF-8"?>
    <beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:context="http://www.springframework.org/schema/context"
    xmlns:mvc="http://www.springframework.org/schema/mvc"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.springframework.org/schema/beans
    http://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/context
    http://www.springframework.org/schema/context/spring-context.xsd
    http://www.springframework.org/schema/mvc
    http://www.springframework.org/schema/mvc/spring-mvc.xsd">
    <!--扫描加载所有的控制类-->
    <context:component-scan base-package="controller"/>
    </beans>

加载控制

Controller 加载控制:SpringMVC 的处理器对应的 bean 必须按照规范格式开发,未避免加入无效的 bean 可通过 bean 加载过滤器进行包含设定或排除设定,表现层 bean 标注通常设定为 @Controller

  • resources / spring-mvc.xml 配置

    1
    2
    3
    4
    5
    <context:component-scan base-package="com.seazean">
    <context:include-filter
    type="annotation"
    expression="org.springframework.stereotype.Controller"/>
    </context:component-scan>
  • 静态资源加载(webapp 目录下的相关资源),spring-mvc.xml 配置,开启 mvc 命名空间

    1
    2
    3
    4
    5
    6
    7
    <!--放行指定类型静态资源配置方式-->
    <mvc:resources mapping="/img/**" location="/img/"/> <!--webapp/img/资源-->
    <mvc:resources mapping="/js/**" location="/js/"/>
    <mvc:resources mapping="/css/**" location="/css/"/>

    <!--SpringMVC 提供的通用资源放行方式,建议选择-->
    <mvc:default-servlet-handler/>
  • 中文乱码处理 SpringMVC 提供专用的中文字符过滤器,用于处理乱码问题。配置在 web.xml 里面

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <!--乱码处理过滤器,与Servlet中使用的完全相同,差异之处在于处理器的类由Spring提供-->
    <filter>
    <filter-name>CharacterEncodingFilter</filter-name>
    <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
    <init-param>
    <param-name>encoding</param-name>
    <param-value>UTF-8</param-value>
    </init-param>
    </filter>
    <filter-mapping>
    <filter-name>CharacterEncodingFilter</filter-name>
    <url-pattern>/*</url-pattern>
    </filter-mapping>

注解驱动

WebApplicationContext,生成 Spring 核心容器(主容器/父容器/根容器)

  • 父容器:Spring 环境加载后形成的容器,包含 Spring 环境下的所有的 bean
  • 子容器:当前 mvc 环境加载后形成的容器,不包含 Spring 环境下的 bean
  • 子容器可以访问父容器中的资源,父容器不可以访问子容器的资源

EnableWebMvc 注解作用:

  • 支持 ConversionService 的配置,可以方便配置自定义类型转换器
  • 支持 @NumberFormat 注解格式化数字类型
  • 支持 @DateTimeFormat 注解格式化日期数据,日期包括 Date、Calendar
  • 支持 @Valid 的参数校验(需要导入 JSR-303 规范)
  • 配合第三方 jar 包和 SpringMVC 提供的注解读写 XML 和 JSON 格式数据

纯注解开发:

  • 使用注解形式转化 SpringMVC 核心配置文件为配置类 java / config / SpringMVCConfiguration.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    @Configuration
    @ComponentScan(value = "com.seazean", includeFilters = @ComponentScan.Filter(
    type=FilterType.ANNOTATION,
    classes = {Controller.class} )
    )
    //等同于<mvc:annotation-driven/>,还不完全相同
    @EnableWebMvc
    public class SpringMVCConfiguration implements WebMvcConfigurer{
    //注解配置通用放行资源的格式 建议使用
    @Override
    public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
    configurer.enable();
    }
    }
  • 基于 servlet3.0 规范,自定义 Servlet 容器初始化配置类,加载 SpringMVC 核心配置类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    public class ServletContainersInitConfig extends AbstractDispatcherServletInitializer {
    //创建Servlet容器时,使用注解方式加载SPRINGMVC配置类中的信息,
    //并加载成WEB专用的ApplicationContext对象该对象放入了ServletContext范围,
    //在整个WEB容器中可以随时获取调用
    @Override
    protected WebApplicationContext createServletApplicationContext() {
    A.C.W.A ctx = new AnnotationConfigWebApplicationContext();
    ctx.register(SpringMVCConfiguration.class);
    return ctx;
    }

    //注解配置映射地址方式,服务于SpringMVC的核心控制器DispatcherServlet
    @Override
    protected String[] getServletMappings() {
    return new String[]{"/"};
    }

    @Override
    protected WebApplicationContext createRootApplicationContext() {
    return null;
    }

    //乱码处理作为过滤器,在servlet容器启动时进行配置
    @Override
    public void onStartup(ServletContext servletContext) throws ServletException {
    super.onStartup(servletContext);
    CharacterEncodingFilter cef = new CharacterEncodingFilter();
    cef.setEncoding("UTF-8");
    FilterRegistration.Dynamic registration = servletContext.addFilter("characterEncodingFilter", cef);
    registration.addMappingForUrlPatterns(EnumSet.of(
    DispatcherType.REQUEST,
    DispatcherType.FORWARD,
    DispatcherType.INCLUDE), false,"/*");
    }
    }

请求映射

名称:@RequestMapping

类型:方法注解、类注解

位置:处理器类中的方法定义上方、处理器类定义上方

  • 方法注解

    作用:绑定请求地址与对应处理方法间的关系

    无类映射地址访问格式: http://localhost/requestURL2

    1
    2
    3
    4
    5
    6
    7
    @Controller
    public class UserController {
    @RequestMapping("/requestURL2")
    public String requestURL2() {
    return "page.jsp";
    }
    }
  • 类注解

    作用:为当前处理器中所有方法设定公共的访问路径前缀

    带有类映射地址访问格式,将类映射地址作为前缀添加在实际映射地址前面:**/user/requestURL1**

    最终返回的页面如果未设定绝对访问路径,将从类映射地址所在目录中查找 webapp/user/page.jsp

    1
    2
    3
    4
    5
    6
    7
    8
    @Controller
    @RequestMapping("/user")
    public class UserController {
    @RequestMapping("/requestURL2")
    public String requestURL2() {
    return "page.jsp";
    }
    }
  • 常用属性

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @RequestMapping(
    value="/requestURL3", //设定请求路径,与path属性、 value属性相同
    method = RequestMethod.GET, //设定请求方式
    params = "name", //设定请求参数条件
    headers = "content-type=text/*", //设定请求消息头条件
    consumes = "text/*", //用于指定可以接收的请求正文类型(MIME类型)
    produces = "text/*" //用于指定可以生成的响应正文类型(MIME类型)
    )
    public String requestURL3() {
    return "/page.jsp";
    }

基本操作

请求处理

普通类型

SpringMVC 将传递的参数封装到处理器方法的形参中,达到快速访问参数的目的

  • 访问 URL:http://localhost/requestParam1?name=seazean&age=14

    1
    2
    3
    4
    5
    6
    7
    8
    @Controller
    public class UserController {
    @RequestMapping("/requestParam1")
    public String requestParam1(String name ,int age){
    System.out.println("name=" + name + ",age=" + age);
    return "page.jsp";
    }
    }
    1
    2
    3
    4
    5
    6
    <%@page pageEncoding="UTF-8" language="java" contentType="text/html;UTF-8" %>
    <html>
    <body>
    <h1>请求参数测试页面</h1>
    </body>
    </html>

@RequestParam 的使用:

  • 类型:形参注解

  • 位置:处理器类中的方法形参前方

  • 作用:绑定请求参数与对应处理方法形参间的关系

  • 访问 URL:http://localhost/requestParam2?userName=Jock

    1
    2
    3
    4
    5
    6
    7
    8
    @RequestMapping("/requestParam2")
    public String requestParam2(@RequestParam(
    name = "userName",
    required = true, //为true代表必须有参数
    defaultValue = "s") String name){
    System.out.println("name=" + name);
    return "page.jsp";
    }

POJO类型

简单类型

当 POJO 中使用简单类型属性时, 参数名称与 POJO 类属性名保持一致

  • 访问 URL: http://localhost/requestParam3?name=seazean&age=14

    1
    2
    3
    4
    5
    @RequestMapping("/requestParam3")
    public String requestParam3(User user){
    System.out.println("name=" + user.getName());
    return "page.jsp";
    }
    1
    2
    3
    4
    5
    public class User {
    private String name;
    private Integer age;
    //......
    }

参数冲突

当 POJO 类型属性与其他形参出现同名问题时,将被同时赋值,建议使用 @RequestParam 注解进行区分


复杂类型

当 POJO 中出现对象属性时,参数名称与对象层次结构名称保持一致

  • 访问 URL: http://localhost/requestParam5?address.province=beijing

    1
    2
    3
    4
    5
    @RequestMapping("/requestParam5")
    public String requestParam5(User user){
    System.out.println("user.address=" + user.getAddress().getProvince());
    return "page.jsp";
    }
    1
    2
    3
    4
    5
    public class User {
    private String name;
    private Integer age;
    private Address address; //....
    }
    1
    2
    3
    4
    5
    public class Address {
    private String province;
    private String city;
    private String address;
    }

容器类型

POJO 中出现集合类型的处理方式

  • 通过 URL 地址中同名参数,可以为 POJO 中的集合属性进行赋值,集合属性要求保存简单数据

    访问 URL:http://localhost/requestParam6?nick=Jock1&nick=Jockme&nick=zahc

    1
    2
    3
    4
    5
    6
    @RequestMapping("/requestParam6")
    public String requestParam6(User user){
    System.out.println("user=" + user);
    //user = User{name='null',age=null,nick={Jock1,Jockme,zahc}}
    return "page.jsp";
    }
    1
    2
    3
    4
    5
    public class User {
    private String name;
    private Integer age;
    private List<String> nick;
    }
  • POJO 中出现 List 保存对象数据,参数名称与对象层次结构名称保持一致,使用数组格式描述集合中对象的位置访问 URL:http://localhost/requestParam7?addresses[0].province=bj&addresses[1].province=tj

    1
    2
    3
    4
    5
    6
    @RequestMapping("/requestParam7")
    public String requestParam7(User user){
    System.out.println("user.addresses=" + user.getAddress());
    //{Address{provice=bj,city='null',address='null'}},{Address{....}}
    return "page.jsp";
    }
    1
    2
    3
    4
    5
    public class User {
    private String name;
    private Integer age;
    private List<Address> addresses;
    }
  • POJO 中出现 Map 保存对象数据,参数名称与对象层次结构名称保持一致,使用映射格式描述集合中对象位置

    URL: http://localhost/requestParam8?addressMap[’home’].province=bj&addressMap[’job’].province=tj

    1
    2
    3
    4
    5
    6
    @RequestMapping("/requestParam8")
    public String requestParam8(User user){
    System.out.println("user.addressMap=" + user.getAddressMap());
    //user.addressMap={home=Address{p=,c=,a=},job=Address{....}}
    return "page.jsp";
    }
    1
    2
    3
    4
    public class User {
    private Map<String,Address> addressMap;
    //....
    }

数组集合

数组类型

请求参数名与处理器方法形参名保持一致,且请求参数数量> 1个


集合类型

保存简单类型数据,请求参数名与处理器方法形参名保持一致,且请求参数数量> 1个

  • 访问 URL: http://localhost/requestParam10?nick=Jockme&nick=zahc

    1
    2
    3
    4
    5
    @RequestMapping("/requestParam10")
    public String requestParam10(@RequestParam("nick") List<String> nick){
    System.out.println(nick);
    return "page.jsp";
    }
  • 注意: SpringMVC 默认将 List 作为对象处理,赋值前先创建对象,然后将 nick 作为对象的属性进行处理。List 是接口无法创建对象,报无法找到构造方法异常;修复类型为可创建对象的 ArrayList 类型后,对象可以创建但没有 nick 属性,因此数据为空
    解决方法:需要告知 SpringMVC 的处理器 nick 是一组数据,而不是一个单一属性。通过 @RequestParam 注解,将数量大于 1 个 names 参数打包成参数数组后, SpringMVC 才能识别该数据格式,并判定形参类型是否为数组或集合,并按数组或集合对象的形式操作数据


转换器

类型

开启转换配置:<mvc:annotation-driven />
作用:提供 Controller 请求转发,Json 自动转换等功能

如果访问 URL:http://localhost/requestParam1?name=seazean&age=seazean,会出现报错,类型转化异常

1
2
3
4
5
@RequestMapping("/requestParam1")
public String requestParam1(String name ,int age){
System.out.println("name=" + name + ",age=" + age);
return "page.jsp";
}

SpringMVC 对接收的数据进行自动类型转换,该工作通过 Converter 接口实现:

  • 标量转换器

  • 集合、数组相关转换器

  • 默认转换器


日期

如果访问 URL:http://localhost/requestParam11?date=1999-09-09 会报错,所以需要日期类型转换

  • 声明自定义的转换格式并覆盖系统转换格式,配置 resources / spring-mvc.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <!--5.启用自定义Converter-->
    <mvc:annotation-driven conversion-service="conversionService"/>
    <!--1.设定格式类型Converter,注册为Bean,受SpringMVC管理-->
    <bean id="conversionService" class="org.springframework.format.support.FormattingConversionServiceFactoryBean">
    <!--2.自定义Converter格式类型设定,该设定使用的是同类型覆盖的思想-->
    <property name="formatters">
    <!--3.使用set保障相同类型的转换器仅保留一个,避免冲突-->
    <set>
    <!--4.设置具体的格式类型-->
    <bean class="org.springframework.format.datetime.DateFormatter">
    <!--5.类型规则-->
    <property name="pattern" value="yyyy-MM-dd"/>
    </bean>
    </set>
    </property>
    </bean>
  • @DateTimeFormat
    类型:形参注解、成员变量注解
    位置:形参前面 或 成员变量上方
    作用:为当前参数或变量指定类型转换规则

    1
    2
    3
    4
    public String requestParam12(@DateTimeFormat(pattern = "yyyy-MM-dd") Date date){
    System.out.println("date=" + date);
    return "page.jsp";
    }
    1
    2
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private Date date;

    依赖注解驱动支持,xml 开启配置:

    1
    <mvc:annotation-driven />  

自定义

自定义类型转换器,实现 Converter 接口或者直接容器中注入:

  • 方式一:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
      public class WebConfig implements WebMvcConfigurer {
    @Bean
    public WebMvcConfigurer webMvcConfigurer() {
    return new WebMvcConfigurer() {
    @Override
    public void addFormatters(FormatterRegistry registry) {
    registry.addConverter(new Converter<String, Date>() {
    @Override
    public Pet convert(String source) {
    DateFormat df = new SimpleDateFormat("yyyy-MM-dd");
    Date date = null;
    //类型转换器无法预计使用过程中出现的异常,因此必须在类型转换器内部捕获,
    //不允许抛出,框架无法预计此类异常如何处理
    try {
    date = df.parse(source);
    } catch (ParseException e) {
    e.printStackTrace();
    }
    return date;
    }
    });
    }
    }
    }

    * 方式二:

    ```java
    //本例中的泛型填写的是String,Date,最终出现字符串转日期时,该类型转换器生效
    public class MyDateConverter implements Converter<String, Date> {
    //重写接口的抽象方法,参数由泛型决定
    public Date convert(String source) {
    DateFormat df = new SimpleDateFormat("yyyy-MM-dd");
    Date date = null;
    //类型转换器无法预计使用过程中出现的异常,因此必须在类型转换器内部捕获,
    //不允许抛出,框架无法预计此类异常如何处理
    try {
    date = df.parse(source);
    } catch (ParseException e) {
    e.printStackTrace();
    }
    return date;
    }
    }

    配置 resources / spring-mvc.xml,注册自定义转换器,将功能加入到 SpringMVC 转换服务 ConverterService 中

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    <!--1.将自定义Converter注册为Bean,受SpringMVC管理-->
    <bean id="myDateConverter" class="converter.MyDateConverter"/>
    <!--2.设定自定义Converter服务bean-->
    <bean id="conversionService"
    class="org.springframework.context.support.ConversionServiceFactoryBean">
    <!--3.注入所有的自定义Converter,该设定使用的是同类型覆盖的思想-->
    <property name="converters">
    <!--4.set保障同类型转换器仅保留一个,去重规则以Converter<S,T>的泛型为准-->
    <set>
    <!--5.具体的类型转换器-->
    <ref bean="myDateConverter"/>
    </set>
    </property>
    </bean>

    <!--开启注解驱动,加载自定义格式化转换器对应的类型转换服务-->
    <mvc:annotation-driven conversion-service="conversionService"/>
  • 使用转换器

    1
    2
    3
    4
    5
    @RequestMapping("/requestParam12")
    public String requestParam12(Date date){
    System.out.println(date);
    return "page.jsp";
    }

响应处理

页面跳转

请求转发和重定向:

  • 请求转发:

    1
    2
    3
    4
    5
    6
    7
    8
    @Controller
    public class UserController {
    @RequestMapping("/showPage1")
    public String showPage1() {
    System.out.println("user mvc controller is running ...");
    return "forward:/WEB-INF/page/page.jsp;
    }
    }
  • 请求重定向:

    1
    2
    3
    4
    5
    @RequestMapping("/showPage2")
    public String showPage2() {
    System.out.println("user mvc controller is running ...");
    return "redirect:/WEB-INF/page/page.jsp";//不能访问WEB-INF下的资源
    }

页面访问快捷设定(InternalResourceViewResolver):

  • 展示页面的保存位置通常固定且结构相似,可以设定通用的访问路径简化页面配置,配置 spring-mvc.xml:

    1
    2
    3
    4
    <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
    <property name="prefix" value="/WEB-INF/pages/"/>
    <property name="suffix" value=".jsp"/>
    </bean>
  • 简化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    @RequestMapping("/showPage3")
    public String showPage3() {
    System.out.println("user mvc controller is running...");
    return "page";
    }
    @RequestMapping("/showPage4")
    public String showPage4() {
    System.out.println("user mvc controller is running...");
    return "forward:page";
    }

    @RequestMapping("/showPage5")
    public String showPage5() {
    System.out.println("user mvc controller is running...");
    return "redirect:page";
    }
  • 如果未设定了返回值,使用 void 类型,则默认使用访问路径作页面地址的前缀后缀

    1
    2
    3
    4
    5
    //最简页面配置方式,使用访问路径作为页面名称,省略返回值
    @RequestMapping("/showPage6")
    public void showPage6() {
    System.out.println("user mvc controller is running ...");
    }

数据跳转

ModelAndView 是 SpringMVC 提供的一个对象,该对象可以用作控制器方法的返回值(Model 同),实现携带数据跳转

作用:

  • 设置数据,向请求域对象中存储数据
  • 设置视图,逻辑视图

代码实现:

  • 使用 HttpServletRequest 类型形参进行数据传递

    1
    2
    3
    4
    5
    6
    7
    8
    @Controller
    public class BookController {
    @RequestMapping("/showPageAndData1")
    public String showPageAndData1(HttpServletRequest request) {
    request.setAttribute("name","seazean");
    return "page";
    }
    }
  • 使用 Model 类型形参进行数据传递

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @RequestMapping("/showPageAndData2")
    public String showPageAndData2(Model model) {
    model.addAttribute("name","seazean");
    Book book = new Book();
    book.setName("SpringMVC入门实战");
    book.setPrice(66.6d);
    //添加数据的方式,key对value
    model.addAttribute("book",book);
    return "page";
    }
    1
    2
    3
    4
    public class Book {
    private String name;
    private Double price;
    }
  • 使用 ModelAndView 类型形参进行数据传递,将该对象作为返回值传递给调用者

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    @RequestMapping("/showPageAndData3")
    public ModelAndView showPageAndData3(ModelAndView modelAndView) {
    //ModelAndView mav = new ModelAndView(); 替换形参中的参数
    Book book = new Book();
    book.setName("SpringMVC入门案例");
    book.setPrice(66.66d);

    //添加数据的方式,key对value
    modelAndView.addObject("book",book);
    modelAndView.addObject("name","Jockme");
    //设置页面的方式,该方法最后一次执行的结果生效
    modelAndView.setViewName("page");
    //返回值设定成ModelAndView对象
    return modelAndView;
    }
  • ModelAndView 扩展

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    //ModelAndView对象支持转发的手工设定,该设定不会启用前缀后缀的页面拼接格式
    @RequestMapping("/showPageAndData4")
    public ModelAndView showPageAndData4(ModelAndView modelAndView) {
    modelAndView.setViewName("forward:/WEB-INF/page/page.jsp");
    return modelAndView;
    }

    //ModelAndView对象支持重定向的手工设定,该设定不会启用前缀后缀的页面拼接格式
    @RequestMapping("/showPageAndData5")
    public ModelAndView showPageAndData6(ModelAndView modelAndView) {
    modelAndView.setViewName("redirect:page.jsp");
    return modelAndView;
    }

JSON

注解:@ResponseBody

作用:将 Controller 的方法返回的对象通过适当的转换器转换为指定的格式之后,写入到 Response 的 body 区。如果返回值是字符串,那么直接将字符串返回客户端;如果是一个对象,会将对象转化为 JSON,返回客户端

注意:当方法上面没有写 ResponseBody,底层会将方法的返回值封装为 ModelAndView 对象

  • 使用 HttpServletResponse 对象响应数据

    1
    2
    3
    4
    5
    6
    7
    @Controller
    public class AccountController {
    @RequestMapping("/showData1")
    public void showData1(HttpServletResponse response) throws IOException {
    response.getWriter().write("message");
    }
    }
  • 使用 @ResponseBody 将返回的结果作为响应内容(页面显示),而非响应的页面名称

    1
    2
    3
    4
    5
    @RequestMapping("/showData2")
    @ResponseBody
    public String showData2(){
    return "{'name':'Jock'}";
    }
  • 使用 jackson 进行 json 数据格式转化

    导入坐标:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    <!--json相关坐标3个-->
    <dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-core</artifactId>
    <version>2.9.0</version>
    </dependency>

    <dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.9.0</version>
    </dependency>

    <dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-annotations</artifactId>
    <version>2.9.0</version>
    </dependency>
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @RequestMapping("/showData3")
    @ResponseBody
    public String showData3() throws JsonProcessingException {
    Book book = new Book();
    book.setName("SpringMVC入门案例");
    book.setPrice(66.66d);

    ObjectMapper om = new ObjectMapper();
    return om.writeValueAsString(book);
    }
  • 使用 SpringMVC 提供的消息类型转换器将对象与集合数据自动转换为 JSON 数据

    1
    2
    3
    4
    5
    6
    7
    8
    9
    //使用SpringMVC注解驱动,对标注@ResponseBody注解的控制器方法进行结果转换,由于返回值为引用类型,自动调用jackson提供的类型转换器进行格式转换
    @RequestMapping("/showData4")
    @ResponseBody
    public Book showData4() {
    Book book = new Book();
    book.setName("SpringMVC入门案例");
    book.setPrice(66.66d);
    return book;
    }
    • 手工添加信息类型转换器

      1
      2
      3
      4
      5
      6
      7
      8
      9
      <bean class="org.springframework.web.servlet.mvc.method.
      annotation.RequestMappingHandlerAdapter">
      <property name="messageConverters">
      <list>
      <bean class="org.springframework.http.converter.
      json.MappingJackson2HttpMessageConverter"/>
      </list>
      </property>
      </bean
    • 使用 SpringMVC 注解驱动:

      1
      2
      <!--开启springmvc注解驱动,对@ResponseBody的注解进行格式增强,追加其类型转换的功能,具体实现由MappingJackson2HttpMessageConverter进行-->
      <mvc:annotation-driven/>
  • 转换集合类型数据

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    @RequestMapping("/showData5")
    @ResponseBody
    public List showData5() {
    Book book1 = new Book();
    book1.setName("SpringMVC入门案例");
    book1.setPrice(66.66d);

    Book book2 = new Book();
    book2.setName("SpringMVC入门案例");
    book2.setPrice(66.66d);

    ArrayList al = new ArrayList();
    al.add(book1);
    al.add(book2);
    return al;
    }

Restful

基本介绍

Rest(REpresentational State Transfer):表现层状态转化,定义了资源在网络传输中以某种表现形式进行状态转移,即网络资源的访问方式

  • 资源:把真实的对象数据称为资源,一个资源既可以是一个集合,也可以是单个个体;每一种资源都有特定的 URI(统一资源标识符)与之对应,如果获取这个资源,访问这个 URI 就可以,比如获取特定的班级 /class/12;资源也可以包含子资源,比如 /classes/classId/teachers 某个指定班级的所有老师
  • 表现形式:资源是一种信息实体,它可以有多种外在表现形式,把资源具体呈现出来的形式比如 json、xml、image、txt 等等叫做它的”表现层/表现形式”
  • 状态转移:描述的服务器端资源的状态,比如增删改查(通过 HTTP 动词实现)引起资源状态的改变,互联网通信协议 HTTP 协议,是一个无状态协议,所有的资源状态都保存在服务器端

访问方式

Restful 是按照 Rest 风格访问网络资源

优点:隐藏资源的访问行为,通过地址无法得知做的是何种操作,书写简化

Restful 请求路径简化配置方式:@RestController = @Controller + @ResponseBody

相关注解:@GetMapping 注解是 @RequestMapping 注解的衍生,所以效果是一样的,建议使用 @GetMapping

  • @GetMapping("/poll") = @RequestMapping(value = "/poll",method = RequestMethod.GET)

    1
    2
    3
    4
    5
    @RequestMapping(method = RequestMethod.GET)			// @GetMapping 就拥有了 @RequestMapping 的功能
    public @interface GetMapping {
    @AliasFor(annotation = RequestMapping.class) // 与 RequestMapping 相通
    String name() default "";
    }
  • @PostMapping("/push") = @RequestMapping(value = "/push",method = RequestMethod.POST)

过滤器:HiddenHttpMethodFilter 是 SpringMVC 对 Restful 风格的访问支持的过滤器

代码实现:

  • restful.jsp:

    • 页面表单使用隐藏域提交请求类型,参数名称固定为 _method,必须配合提交类型 method=post 使用

    • GET 请求通过地址栏可以发送,也可以通过设置 form 的请求方式提交

    • POST 请求必须通过 form 的请求方式提交

    1
    2
    3
    4
    5
    6
    7
    <h1>restful风格请求表单</h1>
    <!--切换请求路径为restful风格-->
    <form action="/user" method="post">
    <!--一隐藏域,切换为PUT请求或DELETE请求,但是form表单的提交方式method属性必须填写post-->
    <input name="_method" type="hidden" value="PUT"/>
    <input value="REST-PUT 提交" type="submit"/>
    </form>
  • java / controller / UserController

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    @RestController				//设置rest风格的控制器
    @RequestMapping("/user/") //设置公共访问路径,配合下方访问路径使用
    public class UserController {
    @GetMapping("/user")
    //@RequestMapping(value = "/user",method = RequestMethod.GET)
    public String getUser(){
    return "GET-张三";
    }

    @PostMapping("/user")
    //@RequestMapping(value = "/user",method = RequestMethod.POST)
    public String saveUser(){
    return "POST-张三";
    }

    @PutMapping("/user")
    //@RequestMapping(value = "/user",method = RequestMethod.PUT)
    public String putUser(){
    return "PUT-张三";
    }

    @DeleteMapping("/user")
    //@RequestMapping(value = "/user",method = RequestMethod.DELETE)
    public String deleteUser(){
    return "DELETE-张三";
    }
    }
  • 配置拦截器 web.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <!--配置拦截器,解析请求中的参数_method,否则无法发起PUT请求与DELETE请求,配合页面表单使用-->
    <filter>
    <filter-name>HiddenHttpMethodFilter</filter-name>
    <filter-class>org.springframework.web.filter.HiddenHttpMethodFilter</filter-class>
    </filter>
    <filter-mapping>
    <filter-name>HiddenHttpMethodFilter</filter-name>
    <servlet-name>DispatcherServlet</servlet-name>
    </filter-mapping>

参数注解

Restful 开发中的参数注解

1
2
3
@GetMapping("{id}")
public String getMessage(@PathVariable("id") Integer id){
}

使用 @PathVariable 注解获取路径上配置的具名变量,一般在有多个参数的时候添加

其他注解:

  • @RequestHeader:获取请求头
  • @RequestParam:获取请求参数(指问号后的参数,url?a=1&b=2)
  • @CookieValue:获取 Cookie 值
  • @RequestAttribute:获取 request 域属性
  • @RequestBody:获取请求体 [POST]
  • @MatrixVariable:矩阵变量
  • @ModelAttribute:自定义类型变量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@RestController	
@RequestMapping("/user/")
public class UserController {
//rest风格访问路径简化书写方式,配合类注解@RequestMapping使用
@RequestMapping("{id}")
public String restLocation2(@PathVariable Integer id){
System.out.println("restful is running ....get:" + id);
return "success.jsp";
}

//@RequestMapping(value = "{id}",method = RequestMethod.GET)
@GetMapping("{id}")
public String get(@PathVariable Integer id){
System.out.println("restful is running ....get:" + id);
return "success.jsp";
}

@PostMapping("{id}")
public String post(@PathVariable Integer id){
System.out.println("restful is running ....post:" + id);
return "success.jsp";
}

@PutMapping("{id}")
public String put(@PathVariable Integer id){
System.out.println("restful is running ....put:" + id);
return "success.jsp";
}

@DeleteMapping("{id}")
public String delete(@PathVariable Integer id){
System.out.println("restful is running ....delete:" + id);
return "success.jsp";
}
}

识别原理

表单提交要使用 REST 时,会带上 _method=PUT,请求过来被 HiddenHttpMethodFilter 拦截,进行过滤操作

org.springframework.web.filter.HiddenHttpMethodFilter.doFilterInternal():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class HiddenHttpMethodFilter extends OncePerRequestFilter {
// 兼容的请求 PUT、DELETE、PATCH
private static final List<String> ALLOWED_METHODS =
Collections.unmodifiableList(Arrays.asList(HttpMethod.PUT.name(),
HttpMethod.DELETE.name(), HttpMethod.PATCH.name()));
// 隐藏域的名字
public static final String DEFAULT_METHOD_PARAM = "_method";

private String methodParam = DEFAULT_METHOD_PARAM;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {

HttpServletRequest requestToUse = request;
// 请求必须是 POST,
if ("POST".equals(request.getMethod()) && request.getAttribute(WebUtils.ERROR_EXCEPTION_ATTRIBUTE) == null) {
// 获取标签中 name="_method" 的 value 值
String paramValue = request.getParameter(this.methodParam);
if (StringUtils.hasLength(paramValue)) {
// 转成大写
String method = paramValue.toUpperCase(Locale.ENGLISH);
// 兼容的请求方式
if (ALLOWED_METHODS.contains(method)) {
// 包装请求
requestToUse = new HttpMethodRequestWrapper(request, method);
}
}
}
// 过滤器链放行的时候用wrapper。以后的方法调用getMethod是调用requesWrapper的
filterChain.doFilter(requestToUse, response);
}
}

Rest 使用客户端工具,如 Postman 可直接发送 put、delete 等方式请求不被过滤

改变默认的 _method 的方式:

1
2
3
4
5
6
7
8
9
10
11
@Configuration(proxyBeanMethods = false)
public class WebConfig{
//自定义filter
@Bean
public HiddenHttpMethodFilter hiddenHttpMethodFilter(){
HiddenHttpMethodFilter methodFilter = new HiddenHttpMethodFilter();
//通过set 方法自定义
methodFilter.setMethodParam("_m");
return methodFilter;
}
}

Servlet

SpringMVC 提供访问原始 Servlet 接口的功能

  • SpringMVC 提供访问原始 Servlet 接口 API 的功能,通过形参声明即可

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @RequestMapping("/servletApi")
    public String servletApi(HttpServletRequest request,
    HttpServletResponse response, HttpSession session){
    System.out.println(request);
    System.out.println(response);
    System.out.println(session);
    request.setAttribute("name","seazean");
    System.out.println(request.getAttribute("name"));
    return "page.jsp";
    }
  • Head 数据获取快捷操作方式
    名称:@RequestHeader
    类型:形参注解
    位置:处理器类中的方法形参前方
    作用:绑定请求头数据与对应处理方法形参间的关系
    范例:

    1
    2
    3
    4
    5
    快捷操作方式@RequestMapping("/headApi")
    public String headApi(@RequestHeader("Accept-Language") String headMsg){
    System.out.println(headMsg);
    return "page";
    }
  • Cookie 数据获取快捷操作方式
    名称:@CookieValue
    类型:形参注解
    位置:处理器类中的方法形参前方
    作用:绑定请求 Cookie 数据与对应处理方法形参间的关系
    范例:

    1
    2
    3
    4
    5
    @RequestMapping("/cookieApi")
    public String cookieApi(@CookieValue("JSESSIONID") String jsessionid){
    System.out.println(jsessionid);
    return "page";
    }
  • Session 数据获取
    名称:@SessionAttribute
    类型:形参注解
    位置:处理器类中的方法形参前方
    作用:绑定请求Session数据与对应处理方法形参间的关系
    范例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @RequestMapping("/sessionApi")
    public String sessionApi(@SessionAttribute("name") String name){
    System.out.println(name);
    return "page.jsp";
    }
    //用于在session中放入数据
    @RequestMapping("/setSessionData")
    public String setSessionData(HttpSession session){
    session.setAttribute("name","seazean");
    return "page";
    }
  • Session 数据设置
    名称:@SessionAttributes
    类型:类注解
    位置:处理器类上方
    作用:声明放入session范围的变量名称,适用于Model类型数据传参
    范例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    @Controller
    //设定当前类中名称为age和gender的变量放入session范围,不常用
    @SessionAttributes(names = {"age","gender"})
    public class ServletController {
    //将数据放入session存储范围,Model对象实现数据set,@SessionAttributes注解实现范围设定
    @RequestMapping("/setSessionData2")
    public String setSessionDate2(Model model) {
    model.addAttribute("age",39);
    model.addAttribute("gender","男");
    return "page";
    }

    @RequestMapping("/sessionApi")
    public String sessionApi(@SessionAttribute("age") int age,
    @SessionAttribute("gender") String gender){
    System.out.println(name);
    System.out.println(age);
    return "page";
    }
    }
  • spring-mvc.xml 配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <?xml version="1.0" encoding="UTF-8"?>
    <beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:context="http://www.springframework.org/schema/context"
    xmlns:mvc="http://www.springframework.org/schema/mvc"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc.xsd
    http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd">

    <context:component-scan base-package="com.seazean"/>
    <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
    <property name="prefix" value="/WEB-INF/page/"/>
    <property name="suffix" value=".jsp"/>
    </bean>
    <mvc:annotation-driven/>
    </beans>

运行原理

技术架构

组件介绍

核心组件:

  • DispatcherServlet:核心控制器, 是 SpringMVC 的核心,整体流程控制的中心,所有的请求第一步都先到达这里,由其调用其它组件处理用户的请求,它就是在 web.xml 配置的核心 Servlet,有效的降低了组件间的耦合性

  • HandlerMapping:处理器映射器, 负责根据请求找到对应具体的 Handler 处理器,SpringMVC 中针对配置文件方式、注解方式等提供了不同的映射器来处理

  • Handler:处理器,其实就是 Controller,业务处理的核心类,通常由开发者编写,并且必须遵守 Controller 开发的规则,这样适配器才能正确的执行。例如实现 Controller 接口,将 Controller 注册到 IOC 容器中等

  • HandlAdapter:处理器适配器,根据映射器中找到的 Handler,通过 HandlerAdapter 去执行 Handler,这是适配器模式的应用

  • View Resolver:视图解析器, 将 Handler 中返回的逻辑视图(ModelAndView)解析为一个具体的视图(View)对象

  • View:视图, View 最后对页面进行渲染将结果返回给用户,SpringMVC 框架提供了很多的 View 视图类型,包括:jstlView、freemarkerView、pdfView 等

优点:

  • 与 Spring 集成,更好的管理资源
  • 有很多参数解析器和视图解析器,支持的数据类型丰富
  • 将映射器、处理器、视图解析器进行解耦,分工明确

工作原理

在 Spring 容器初始化时会建立所有的 URL 和 Controller 的对应关系,保存到 Map<URL, Controller> 中,这样 request 就能快速根据 URL 定位到 Controller:

  • 在 Spring IOC 容器初始化完所有单例 bean 后
  • SpringMVC 会遍历所有的 bean,获取 Controller 中对应的 URL(这里获取 URL 的实现类有多个,用于处理不同形式配置的 Controller)
  • 将每一个 URL 对应一个 Controller 存入 Map<URL, Controller> 中

注意:将 @Controller 注解换成 @Component,启动时不会报错,但是在浏览器中输入路径时会出现 404,说明 Spring 没有对所有的 bean 进行 URL 映射

一个 Request 来了:

  • 监听端口,获得请求:Tomcat 监听 8080 端口的请求处理,根据路径调用了 web.xml 中配置的核心控制器 DispatcherServlet,DispatcherServlet#doDispatch核心调度方法
  • 首先根据 URI 获取 HandlerMapping 处理器映射器,RequestMappingHandlerMapping 用来处理 @RequestMapping 注解的映射规则,其中保存了所有 handler 的映射规则,最后包装成一个拦截器链返回,拦截器链对象持有 HandlerMapping。如果没有合适的处理请求的 HandlerMapping,说明请求处理失败,设置响应码 404 返回
  • 根据映射器获取当前 handler,处理器适配器执行处理方法,适配器根据请求的 URL 去 handler 中寻找对应的处理方法:
    • 创建 ModelAndViewContainer (mav) 对象,用来填充数据,然后通过不同的参数解析器去解析 URL 中的参数,完成数据解析绑定,然后执行真正的 Controller 方法,完成 handle 处理
    • 方法执行完对返回值进行处理,没添加 @ResponseBody 注解的返回值使用视图处理器处理,把视图名称设置进入 mav 中
    • 对添加了 @ResponseBody 注解的 Controller 的按照普通的返回值进行处理,首先进行内容协商,找到一种浏览器可以接受(请求头 Accept)的并且服务器可以生成的数据类型,选择合适数据转换器,设置响应头中的数据类型,然后写出数据
    • 最后把 ModelAndViewContainer 和 ModelMap 中的数据封装到 ModelAndView 对象返回
  • 视图解析,根据返回值创建视图,请求转发 View 实例为 InternalResourceView,重定向 View 实例为 RedirectView。最后调用 view.render 进行页面渲染,结果派发
    • 请求转发时请求域中的数据不丢失,会把 ModelAndView 的数据设置到请求域中,获取 Servlet 原生的 RequestDispatcher,调用 RequestDispatcher#forward 实现转发
    • 重定向会造成请求域中的数据丢失,使用 Servlet 原生方式实现重定向 HttpServletResponse#sendRedirect

调度函数

请求进入原生的 HttpServlet 的 doGet() 方法处理,调用子类 FrameworkServlet 的 doGet() 方法,最终调用 DispatcherServlet 的 doService() 方法,为请求设置相关属性后调用 doDispatch(),请求和响应的以参数的形式传入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// request 和 response 为 Java 原生的类
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
HttpServletRequest processedRequest = request;
HandlerExecutionChain mappedHandler = null;
// 文件上传请求
boolean multipartRequestParsed = false;
// 异步管理器
WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);

try {
ModelAndView mv = null;
Exception dispatchException = null;

try {
// 文件上传相关请求
processedRequest = checkMultipart(request);
multipartRequestParsed = (processedRequest != request);

// 找到当前请求使用哪个 HandlerMapping (Controller 的方法)处理,返回执行链
mappedHandler = getHandler(processedRequest);
// 没有合适的处理请求的方式 HandlerMapping,请求失败,直接返回 404
if (mappedHandler == null) {
noHandlerFound(processedRequest, response);
return;
}

// 根据映射器获取当前 handler 处理器适配器,用来【处理当前的请求】
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
// 获取发出此次请求的方式
String method = request.getMethod();
// 判断请求是不是 GET 方法
boolean isGet = HttpMethod.GET.matches(method);
if (isGet || HttpMethod.HEAD.matches(method)) {
long lastModified = ha.getLastModified(request, mappedHandler.getHandler());
if (new ServletWebRequest(request, response).checkNotModified(lastModified) && isGet) {
return;
}
}
// 拦截器链的前置处理
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
return;
}
// 执行处理方法,返回的是 ModelAndView 对象,封装了所有的返回值数据
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());

if (asyncManager.isConcurrentHandlingStarted()) {
return;
}
// 设置视图名字
applyDefaultViewName(processedRequest, mv);
// 执行拦截器链中的后置处理方法
mappedHandler.applyPostHandle(processedRequest, response, mv);
} catch (Exception ex) {
dispatchException = ex;
}

// 处理程序调用的结果,进行结果派发
processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
}
//....
}

笔记参考视频:https://www.bilibili.com/video/BV19K4y1L7MT


请求映射

映射器

doDispatch() 中调用 getHandler 方法获取所有的映射器

总体流程:

  • 所有的请求映射都在 HandlerMapping 中,RequestMappingHandlerMapping 处理 @RequestMapping 注解的映射规则

  • 遍历所有的 HandlerMapping 看是否可以匹配当前请求,匹配成功后返回,匹配失败设置 HTTP 404 响应码

  • 用户可以自定义的映射处理,也可以给容器中放入自定义 HandlerMapping

访问 URL:http://localhost:8080/user

1
2
3
4
5
6
7
8
9
@GetMapping("/user")
public String getUser(){
return "GET";
}
@PostMapping("/user")
public String postUser(){
return "POST";
}
//。。。。。

HandlerMapping 处理器映射器,保存了所有 @RequestMappinghandler 的映射规则

1
2
3
4
5
6
7
8
9
10
11
12
13
protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
if (this.handlerMappings != null) {
// 遍历所有的 HandlerMapping
for (HandlerMapping mapping : this.handlerMappings) {
// 尝试去每个 HandlerMapping 中匹配当前请求的处理
HandlerExecutionChain handler = mapping.getHandler(request);
if (handler != null) {
return handler;
}
}
}
return null;
}

  • mapping.getHandler(request):调用 AbstractHandlerMapping#getHandler

    • Object handler = getHandlerInternal(request)获取映射器,底层调用 RequestMappingInfoHandlerMapping 类的方法,又调用 AbstractHandlerMethodMapping#getHandlerInternal

      • String lookupPath = initLookupPath(request):地址栏的 URI,这里的 lookupPath 为 /user

      • this.mappingRegistry.acquireReadLock():加读锁防止其他线程并发修改

      • handlerMethod = lookupHandlerMethod(lookupPath, request):获取当前 HandlerMapping 中的映射规则

        • directPathMatches = this.mappingRegistry.getMappingsByDirectPath(lookupPath):获取当前的映射器与当前请求的 URI 有关的所有映射规则

        • addMatchingMappings(directPathMatches, matches, request)匹配某个映射规则

          • for (T mapping : mappings):遍历所有的映射规则
          • match = getMatchingMapping(mapping, request):去匹配每一个映射规则,匹配失败返回 null
          • matches.add(new Match()):匹配成功后封装成匹配器添加到匹配集合中
        • matches.sort(comparator):匹配集合排序

        • Match bestMatch = matches.get(0):匹配完成只剩一个,直接获取返回对应的处理方法

        • if (matches.size() > 1):当有多个映射规则符合请求时,报错

        • return bestMatch.getHandlerMethod():返回匹配器中的处理方法

    • executionChain = getHandlerExecutionChain(handler, request)为当前请求和映射器的构建一个拦截器链

      • for (HandlerInterceptor interceptor : this.adaptedInterceptors):遍历所有的拦截器
      • chain.addInterceptor(interceptor):把所有的拦截器添加到 HandlerExecutionChain 中,形成拦截器链
    • return executionChain返回拦截器链,HandlerMapping 是链的 handler 成员属性


适配器

doDispatch() 中调用 HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler())

1
2
3
4
5
6
7
8
9
10
11
12
13
14
protected HandlerAdapter getHandlerAdapter(Object handler) throws ServletException {
if (this.handlerAdapters != null) {
// 遍历所有的 HandlerAdapter
for (HandlerAdapter adapter : this.handlerAdapters) {
// 判断当前适配器是否支持当前 handle
if (adapter.supports(handler)) {
// 返回的是 【RequestMappingHandlerAdapter】
// AbstractHandlerMethodAdapter#supports -> RequestMappingHandlerAdapter
return adapter;
}
}
}
throw new ServletException();
}

方法执行

实例代码:

1
2
3
4
5
6
7
@GetMapping("/params")
public String param(Map<String, Object> map, Model model, HttpServletRequest request) {
map.put("k1", "v1"); // 都可以向请求域中添加数据
model.addAttribute("k2", "v2"); // 它们两个都在数据封装在 【BindingAwareModelMap】,继承自 LinkedHashMap
request.setAttribute("m", "HelloWorld");
return "forward:/success";
}

doDispatch() 中调用 mv = ha.handle(processedRequest, response, mappedHandler.getHandler()) 使用适配器执行方法

AbstractHandlerMethodAdapter#handleRequestMappingHandlerAdapter#handleInternalinvokeHandlerMethod

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
protected ModelAndView invokeHandlerMethod(HttpServletRequest request,
HttpServletResponse response,
HandlerMethod handlerMethod) throws Exception {
// 封装成 SpringMVC 的接口,用于通用 Web 请求拦截器,使能够访问通用请求元数据,而不是用于实际处理请求
ServletWebRequest webRequest = new ServletWebRequest(request, response);
try {
// WebDataBinder 用于【从 Web 请求参数到 JavaBean 对象的数据绑定】,获取创建该实例的工厂
WebDataBinderFactory binderFactory = getDataBinderFactory(handlerMethod);
// 创建 Model 实例,用于向模型添加属性
ModelFactory modelFactory = getModelFactory(handlerMethod, binderFactory);
// 方法执行器
ServletInvocableHandlerMethod invocableMethod = createInvocableHandlerMethod(handlerMethod);

// 参数解析器,有很多
if (this.argumentResolvers != null) {
invocableMethod.setHandlerMethodArgumentResolvers(this.argumentResolvers);
}
// 返回值处理器,也有很多
if (this.returnValueHandlers != null) {
invocableMethod.setHandlerMethodReturnValueHandlers(this.returnValueHandlers);
}
// 设置数据绑定器
invocableMethod.setDataBinderFactory(binderFactory);
// 设置参数检查器
invocableMethod.setParameterNameDiscoverer(this.parameterNameDiscoverer);

// 新建一个 ModelAndViewContainer 并进行初始化和一些属性的填充
ModelAndViewContainer mavContainer = new ModelAndViewContainer();

// 设置一些属性

// 【执行目标方法】
invocableMethod.invokeAndHandle(webRequest, mavContainer);
// 异步请求
if (asyncManager.isConcurrentHandlingStarted()) {
return null;
}
// 【获取 ModelAndView 对象,封装了 ModelAndViewContainer】
return getModelAndView(mavContainer, modelFactory, webRequest);
}
finally {
webRequest.requestCompleted();
}
}

ServletInvocableHandlerMethod#invokeAndHandle:执行目标方法

  • returnValue = invokeForRequest(webRequest, mavContainer, providedArgs)执行自己写的 controller 方法,返回的就是自定义方法中 return 的值

    Object[] args = getMethodArgumentValues(request, mavContainer, providedArgs)参数处理的逻辑,遍历所有的参数解析器解析参数或者将 URI 中的参数进行绑定,绑定完成后开始执行目标方法

    • parameters = getMethodParameters():获取此处理程序方法的方法参数的详细信息

    • Object[] args = new Object[parameters.length]:存放所有的参数

    • for (int i = 0; i < parameters.length; i++):遍历所有的参数

    • args[i] = findProvidedArgument(parameter, providedArgs):获取调用方法时提供的参数,一般是空

    • if (!this.resolvers.supportsParameter(parameter))获取可以解析当前参数的参数解析器

      return getArgumentResolver(parameter) != null:获取参数的解析是否为空

      • for (HandlerMethodArgumentResolver resolver : this.argumentResolvers):遍历容器内所有的解析器

        if (resolver.supportsParameter(parameter)):是否支持当前参数

        • PathVariableMethodArgumentResolver#supportsParameter解析标注 @PathVariable 注解的参数
        • ModelMethodProcessor#supportsParameter:解析 Map 和 Model 类型的参数,Model 和 Map 的作用一样
        • ExpressionValueMethodArgumentResolver#supportsParameter:解析标注 @Value 注解的参数
        • RequestParamMapMethodArgumentResolver#supportsParameter解析标注 @RequestParam 注解
        • RequestPartMethodArgumentResolver#supportsParameter:解析文件上传的信息
        • ModelAttributeMethodProcessor#supportsParameter:解析标注 @ModelAttribute 注解或者不是简单类型
          • 子类 ServletModelAttributeMethodProcessor 是解析自定义类型 JavaBean 的解析器
          • 简单类型有 Void、Enum、Number、CharSequence、Date、URI、URL、Locale、Class
    • args[i] = this.resolvers.resolveArgument()开始解析参数,每个参数使用的解析器不同

      resolver = getArgumentResolver(parameter):获取参数解析器

      return resolver.resolveArgument():开始解析

      • PathVariableMapMethodArgumentResolver#resolveArgument:@PathVariable,包装 URI 中的参数为 Map
      • MapMethodProcessor#resolveArgument:调用 mavContainer.getModel() 返回默认 BindingAwareModelMap 对象
      • ModelAttributeMethodProcessor#resolveArgument自定义的 JavaBean 的绑定封装,下一小节详解

    return doInvoke(args)真正的执行 Controller 方法

    • Method method = getBridgedMethod():从 HandlerMethod 获取要反射执行的方法
    • ReflectionUtils.makeAccessible(method):破解权限
    • method.invoke(getBean(), args):执行方法,getBean 获取的是标记 @Controller 的 Bean 类,其中包含执行方法
  • 进行返回值的处理,响应部分详解,处理完成进入下面的逻辑

RequestMappingHandlerAdapter#getModelAndView:获取 ModelAndView 对象

  • modelFactory.updateModel(webRequest, mavContainer):Model 数据升级到会话域(请求域中的数据在重定向时丢失

    • updateBindingResult(request, defaultModel):把绑定的数据添加到 BindingAwareModelMap 中
  • if (mavContainer.isRequestHandled()):判断请求是否已经处理完成了

  • ModelMap model = mavContainer.getModel():获取包含 Controller 方法参数的 BindingAwareModelMap(本节开头)

  • mav = new ModelAndView()把 ModelAndViewContainer 和 ModelMap 中的数据封装到 ModelAndView

  • if (!mavContainer.isViewReference()):是否是通过名称指定视图引用

  • if (model instanceof RedirectAttributes):判断 model 是否是重定向数据,如果是进行重定向逻辑

  • return mav任何方法执行都会返回 ModelAndView 对象


参数解析

解析自定义的 JavaBean 为例,调用 ModelAttributeMethodProcessor#resolveArgument 处理参数的方法,通过合适的类型转换器把 URL 中的参数转换以后,利用反射获取 set 方法,注入到 JavaBean

  • Person.java:

    1
    2
    3
    4
    5
    6
    7
    @Data
    @Component //加入到容器中
    public class Person {
    private String userName;
    private Integer age;
    private Date birth;
    }
  • Controller:

    1
    2
    3
    4
    5
    6
    7
    8
    @RestController	//返回的数据不是页面
    public class ParameterController {
    // 数据绑定:页面提交的请求数据(GET、POST)都可以和对象属性进行绑定
    @GetMapping("/saveuser")
    public Person saveuser(Person person){
    return person;
    }
    }
  • 访问 URL:http://localhost:8080/saveuser?userName=zhangsan&age=20

进入源码:ModelAttributeMethodProcessor#resolveArgument

  • name = ModelFactory.getNameForParameter(parameter):获取名字,此例就是 person

  • ann = parameter.getParameterAnnotation(ModelAttribute.class):是否有 ModelAttribute 注解

  • if (mavContainer.containsAttribute(name)):ModelAndViewContainer 中是否包含 person 对象

  • attribute = createAttribute()创建一个实例,空的 Person 对象

  • binder = binderFactory.createBinder(webRequest, attribute, name):Web 数据绑定器,可以利用 Converters 将请求数据转成指定的数据类型,绑定到 JavaBean 中

  • bindRequestParameters(binder, webRequest)利用反射向目标对象填充数据

    servletBinder = (ServletRequestDataBinder) binder:类型强转

    servletBinder.bind(servletRequest):绑定数据

    • mpvs = new MutablePropertyValues(request.getParameterMap()):获取请求 URI 参数中的 k-v 键值对

    • addBindValues(mpvs, request):子类可以用来为请求添加额外绑定值

    • doBind(mpvs):真正的绑定的方法,调用 applyPropertyValues 应用参数值,然后调用 setPropertyValues 方法

      AbstractPropertyAccessor#setPropertyValues()

      • List<PropertyValue> propertyValues:获取到所有的参数的值,就是 URI 上的所有的参数值

      • for (PropertyValue pv : propertyValues):遍历所有的参数值

      • setPropertyValue(pv)填充到空的 Person 实例中

        • nestedPa = getPropertyAccessorForPropertyPath(propertyName):获取属性访问器

        • tokens = getPropertyNameTokens():获取元数据的信息

        • nestedPa.setPropertyValue(tokens, pv):填充数据

        • processLocalProperty(tokens, pv):处理属性

          • if (!Boolean.FALSE.equals(pv.conversionNecessary)):数据是否需要转换了

          • if (pv.isConverted()):数据已经转换过了,转换了直接赋值,没转换进行转换

          • oldValue = ph.getValue():获取未转换的数据

          • valueToApply = convertForProperty():进行数据转换

            TypeConverterDelegate#convertIfNecessary:进入该方法的逻辑

            • if (conversionService.canConvert(sourceTypeDesc, typeDescriptor)):判断能不能转换

              GenericConverter converter = getConverter(sourceType, targetType)获取类型转换器

              • converter = this.converters.find(sourceType, targetType):寻找合适的转换器

                • sourceCandidates = getClassHierarchy(sourceType.getType()):原数据类型

                • targetCandidates = getClassHierarchy(targetType.getType()):目标数据类型

                  1
                  2
                  3
                  for (Class<?> sourceCandidate : sourceCandidates) {
                  //双重循环遍历,寻找合适的转换器
                  for (Class<?> targetCandidate : targetCandidates) {
                • GenericConverter converter = getRegisteredConverter(..):匹配类型转换器

                • return converter:返回转换器

            • conversionService.convert(newValue, sourceTypeDesc, typeDescriptor):开始转换

              • converter = getConverter(sourceType, targetType)获取可用的转换器
              • result = ConversionUtils.invokeConverter():执行转换方法
                • converter.convert()调用转换器的转换方法(GenericConverter#convert)
              • return handleResult(sourceType, targetType, result):返回结果
          • ph.setValue(valueToApply)设置 JavaBean 属性(BeanWrapperImpl.BeanPropertyHandler)

            • Method writeMethod:获取写数据方法
              • Class<?> cls = getClass0():获取 Class 对象
              • writeMethodName = Introspector.SET_PREFIX + getBaseName()set 前缀 + 属性名
              • writeMethod = Introspector.findMethod(cls, writeMethodName, 1, args):获取只包含一个参数的 set 方法
              • setWriteMethod(writeMethod):加入缓存
            • ReflectionUtils.makeAccessible(writeMethod):设置访问权限
            • writeMethod.invoke(getWrappedInstance(), value):执行方法
  • bindingResult = binder.getBindingResult():获取绑定的结果

  • mavContainer.addAllAttributes(bindingResultModel)把所有填充的参数放入 ModelAndViewContainer

  • return attribute:返回填充后的 Person 对象


响应处理

响应数据

以 Person 为例:

1
2
3
4
5
6
7
8
9
@ResponseBody  		// 利用返回值处理器里面的消息转换器进行处理,而不是视图
@GetMapping(value = "/person")
public Person getPerson(){
Person person = new Person();
person.setAge(28);
person.setBirth(new Date());
person.setUserName("zhangsan");
return person;
}

直接进入方法执行完后的逻辑 ServletInvocableHandlerMethod#invokeAndHandle:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public void invokeAndHandle(ServletWebRequest webRequest, ModelAndViewContainer mavContainer,
Object... providedArgs) throws Exception {
// 【执行目标方法】,return person 对象
Object returnValue = invokeForRequest(webRequest, mavContainer, providedArgs);
// 设置状态码
setResponseStatus(webRequest);

// 判断方法是否有返回值
if (returnValue == null) {
if (isRequestNotModified(webRequest) || getResponseStatus() != null || mavContainer.isRequestHandled()) {
disableContentCachingIfNecessary(webRequest);
mavContainer.setRequestHandled(true);
return;
}
} // 返回值是字符串
else if (StringUtils.hasText(getResponseStatusReason())) {
// 设置请求处理完成
mavContainer.setRequestHandled(true);
return;
// 设置请求没有处理完成,还需要进行返回值的逻辑
mavContainer.setRequestHandled(false);
Assert.state(this.returnValueHandlers != null, "No return value handlers");
try {
// 【返回值的处理】
this.returnValueHandlers.handleReturnValue(
returnValue, getReturnValueType(returnValue), mavContainer, webRequest);
}
catch (Exception ex) {}
}
  • 没有加 @ResponseBody 注解的返回数据按照视图处理的逻辑,ViewNameMethodReturnValueHandler(视图详解)
  • 此例是加了注解的,返回的数据不是视图,HandlerMethodReturnValueHandlerComposite#handleReturnValue:
1
2
3
4
5
6
7
8
9
10
public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,
ModelAndViewContainer mavContainer, NativeWebRequest webRequest) {
// 获取合适的返回值处理器
HandlerMethodReturnValueHandler handler = selectHandler(returnValue, returnType);
if (handler == null) {
throw new IllegalArgumentException();
}
// 使用处理器处理返回值(详解源码中的这两个函数)
handler.handleReturnValue(returnValue, returnType, mavContainer, webRequest);
}

HandlerMethodReturnValueHandlerComposite#selectHandler:获取合适的返回值处理器

  • boolean isAsyncValue = isAsyncReturnValue(value, returnType):是否是异步请求

  • for (HandlerMethodReturnValueHandler handler : this.returnValueHandlers):遍历所有的返回值处理器

    • RequestResponseBodyMethodProcessor#supportsReturnType处理标注 @ResponseBody 注解的返回值
    • ModelAndViewMethodReturnValueHandler#supportsReturnType:处理返回值类型是 ModelAndView 的处理器
    • ModelAndViewResolverMethodReturnValueHandler#supportsReturnType:直接返回 true,处理所有数据

RequestResponseBodyMethodProcessor#handleReturnValue:处理返回值,要进行内容协商

  • mavContainer.setRequestHandled(true):设置请求处理完成

  • inputMessage = createInputMessage(webRequest):获取输入的数据

  • outputMessage = createOutputMessage(webRequest):获取输出的数据

  • writeWithMessageConverters(returnValue, returnType, inputMessage, outputMessage):使用消息转换器进行写出

    • if (value instanceof CharSequence):判断返回的数据是不是字符类型

    • body = value:把 value 赋值给 body,此时 body 中就是自定义方法执行完后的 Person 对象

    • if (isResourceType(value, returnType)):当前数据是不是流数据

    • MediaType selectedMediaType内容协商后选择使用的类型,浏览器和服务器都支持的媒体(数据)类型

    • MediaType contentType = outputMessage.getHeaders().getContentType():获取响应头的数据

    • if (contentType != null && contentType.isConcrete()):判断当前响应头中是否已经有确定的媒体类型

      selectedMediaType = contentType:前置处理已经使用了媒体类型,直接继续使用该类型

    • acceptableTypes = getAcceptableMediaTypes(request)获取浏览器支持的媒体类型,请求头字段

      • this.contentNegotiationManager.resolveMediaTypes():调用该方法
      • for(ContentNegotiationStrategy strategy:this.strategies)默认策略是提取请求头的字段的内容,策略类为HeaderContentNegotiationStrategy,可以配置添加其他类型的策略
        • List<MediaType> mediaTypes = strategy.resolveMediaTypes(request):解析 Accept 字段存储为 List
          • headerValueArray = request.getHeaderValues(HttpHeaders.ACCEPT):获取请求头中 Accept 字段
          • List<MediaType> mediaTypes = MediaType.parseMediaTypes(headerValues):解析成 List 集合
          • MediaType.sortBySpecificityAndQuality(mediaTypes):按照相对品质因数 q 降序排序

  • producibleTypes = getProducibleMediaTypes(request, valueType, targetType)服务器能生成的媒体类型

    • request.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE):从请求域获取默认的媒体类型
      • for (HttpMessageConverter<?> converter : this.messageConverters):遍历所有的消息转换器
      • converter.canWrite(valueClass, null):是否支持当前的类型
      • result.addAll(converter.getSupportedMediaTypes()):把当前 MessageConverter 支持的所有类型放入 result
  • List<MediaType> mediaTypesToUse = new ArrayList<>():存储最佳匹配的集合

  • 内容协商:

    1
    2
    3
    4
    5
    6
    7
    8
    for (MediaType requestedType : acceptableTypes) {				// 遍历所有浏览器能接受的媒体类型
    for (MediaType producibleType : producibleTypes) { // 遍历所有服务器能产出的
    if (requestedType.isCompatibleWith(producibleType)) { // 判断类型是否匹配,最佳匹配
    // 数据协商匹配成功,一般有多种
    mediaTypesToUse.add(getMostSpecificMediaType(requestedType, producibleType));
    }
    }
    }
  • MediaType.sortBySpecificityAndQuality(mediaTypesToUse):按照相对品质因数 q 排序,降序排序,越大的越好

  • for (MediaType mediaType : mediaTypesToUse)遍历所有的最佳匹配,选择一种赋值给选择的类型

  • selectedMediaType = selectedMediaType.removeQualityValue():媒体类型去除相对品质因数

  • for (HttpMessageConverter<?> converter : this.messageConverters)遍历所有的 HTTP 数据转换器

  • GenericHttpMessageConverter genericConverterMappingJackson2HttpMessageConverter 可以将对象写为 JSON

  • ((GenericHttpMessageConverter) converter).canWrite():判断转换器是否可以写出给定的类型

    AbstractJackson2HttpMessageConverter#canWrit

    • if (!canWrite(mediaType)):是否可以写出指定类型

      • MediaType.ALL.equalsTypeAndSubtype(mediaType):是不是 */* 类型
      • getSupportedMediaTypes():支持 application/jsonapplication/*+json 两种类型
        • return true:返回 true
      • objectMapper = selectObjectMapper(clazz, mediaType):选择可以使用的 objectMapper
      • causeRef = new AtomicReference<>():获取并发安全的引用
      • if (objectMapper.canSerialize(clazz, causeRef)):objectMapper 可以序列化当前类
      • return true:返回 true
    • body = getAdvice().beforeBodyWrite()获取要响应的所有数据,就是 Person 对象

  • addContentDispositionHeader(inputMessage, outputMessage):检查路径

  • genericConverter.write(body, targetType, selectedMediaType, outputMessage):调用消息转换器的 write 方法

    AbstractGenericHttpMessageConverter#write:该类的方法

    • addDefaultHeaders(headers, t, contentType)设置响应头中的数据类型

    • writeInternal(t, type, outputMessage)数据写出为 JSON 格式

      • Object value = object:value 引用 Person 对象
      • ObjectWriter objectWriter = objectMapper.writer():获取 ObjectWriter 对象
      • objectWriter.writeValue(generator, value):使用 ObjectWriter 写出数据为 JSON

协商策略

开启基于请求参数的内容协商模式:(SpringBoot 方式)

1
spring.mvc.contentnegotiation:favor-parameter: true  # 开启请求参数内容协商模式

发请求: http://localhost:8080/person?format=json,解析 format

策略类为 ParameterContentNegotiationStrategy,运行流程如下:

  • acceptableTypes = getAcceptableMediaTypes(request):获取浏览器支持的媒体类型

    mediaTypes = strategy.resolveMediaTypes(request):解析请求 URL 参数中的数据

    • return resolveMediaTypeKey(webRequest, getMediaTypeKey(webRequest))

      getMediaTypeKey(webRequest)

      • request.getParameter(getParameterName()):获取 URL 中指定的需求的数据类型
        • getParameterName():获取参数的属性名 format
        • getParameter()获取 URL 中 format 对应的数据

      resolveMediaTypeKey():解析媒体类型,封装成集合

自定义内容协商策略:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class WebConfig implements WebMvcConfigurer {
@Bean
public WebMvcConfigurer webMvcConfigurer() {
return new WebMvcConfigurer() {
@Override //自定义内容协商策略
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
Map<String, MediaType> mediaTypes = new HashMap<>();
mediaTypes.put("json", MediaType.APPLICATION_JSON);
mediaTypes.put("xml",MediaType.APPLICATION_XML);
mediaTypes.put("person",MediaType.parseMediaType("application/x-person"));
// 指定支持解析哪些参数对应的哪些媒体类型
ParameterContentNegotiationStrategy parameterStrategy = new ParameterContentNegotiationStrategy(mediaTypes);

// 请求头解析
HeaderContentNegotiationStrategy headStrategy = new HeaderContentNegotiationStrategy();

// 添加到容器中,即可以解析请求头 又可以解析请求参数
configurer.strategies(Arrays.asList(parameterStrategy,headStrategy));
}

@Override // 自定义消息转换器
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.add(new GuiguMessageConverter());
}
}
}
}

也可以自定义 HttpMessageConverter,实现 HttpMessageConverter 接口重写方法即可


视图解析

返回解析

请求处理:

1
2
3
4
5
@GetMapping("/params")
public String param(){
return "forward:/success";
//return "redirect:/success";
}

进入执行方法逻辑 ServletInvocableHandlerMethod#invokeAndHandle,进入 this.returnValueHandlers.handleReturnValue

1
2
3
4
5
6
7
8
9
10
public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,
ModelAndViewContainer mavContainer, NativeWebRequest webRequest) {
// 获取合适的返回值处理器:调用 if (handler.supportsReturnType(returnType))判断是否支持
HandlerMethodReturnValueHandler handler = selectHandler(returnValue, returnType);
if (handler == null) {
throw new IllegalArgumentException();
}
// 使用处理器处理返回值
handler.handleReturnValue(returnValue, returnType, mavContainer, webRequest);
}
  • ViewNameMethodReturnValueHandler#supportsReturnType:

    1
    2
    3
    4
    5
    public boolean supportsReturnType(MethodParameter returnType) {
    Class<?> paramType = returnType.getParameterType();
    // 返回值是否是 void 或者是 CharSequence 字符序列,这里是字符序列
    return (void.class == paramType || CharSequence.class.isAssignableFrom(paramType));
    }
  • ViewNameMethodReturnValueHandler#handleReturnValue:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,
    ModelAndViewContainer mavContainer,
    NativeWebRequest webRequest) throws Exception {
    // 返回值是字符串,是 return "forward:/success"
    if (returnValue instanceof CharSequence) {
    String viewName = returnValue.toString();
    // 【把视图名称设置进入 ModelAndViewContainer 中】
    mavContainer.setViewName(viewName);
    // 判断是否是重定向数据 `viewName.startsWith("redirect:")`
    if (isRedirectViewName(viewName)) {
    // 如果是重定向,设置是重定向指令
    mavContainer.setRedirectModelScenario(true);
    }
    }
    else if (returnValue != null) {
    // should not happen
    throw new UnsupportedOperationException();
    }
    }

结果派发

doDispatch() 中的 processDispatchResult:处理派发结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private void processDispatchResult(HttpServletRequest request, HttpServletResponse response,
@Nullable HandlerExecutionChain mappedHandler,
@Nullable ModelAndView mv,
@Nullable Exception exception) throws Exception {
boolean errorView = false;
if (exception != null) {
}
// mv 是 ModelAndValue
if (mv != null && !mv.wasCleared()) {
// 渲染视图
render(mv, request, response);
if (errorView) {
WebUtils.clearErrorRequestAttributes(request);
}
}
else {}
}

DispatcherServlet#render:

  • Locale locale = this.localeResolver.resolveLocale(request):国际化相关

  • String viewName = mv.getViewName():视图名字,是请求转发 forward:/success(响应数据解析并存入 ModelAndView)

  • view = resolveViewName(viewName, mv.getModelInternal(), locale, request):解析视图

    • for (ViewResolver viewResolver : this.viewResolvers)遍历所有的视图解析器

      view = viewResolver.resolveViewName(viewName, locale):根据视图名字解析视图,调用内容协商视图处理器 ContentNegotiatingViewResolver 的方法

      • attrs = RequestContextHolder.getRequestAttributes():获取请求的相关属性信息

      • requestedMediaTypes = getMediaTypes(((ServletRequestAttributes) attrs).getRequest()):获取最佳匹配的媒体类型,函数内进行了匹配的逻辑

      • candidateViews = getCandidateViews(viewName, locale, requestedMediaTypes):获取候选的视图对象

        • for (ViewResolver viewResolver : this.viewResolvers):遍历所有的视图解析器

        • View view = viewResolver.resolveViewName(viewName, locale)解析视图

          AbstractCachingViewResolver#resolveViewName

          • returnview = createView(viewName, locale):UrlBasedViewResolver#createView

            请求转发:实例为 InternalResourceView

            • if (viewName.startsWith(FORWARD_URL_PREFIX)):视图名字是否是 forward: 的前缀

            • forwardUrl = viewName.substring(FORWARD_URL_PREFIX.length()):名字截取前缀

            • view = new InternalResourceView(forwardUrl):新建 InternalResourceView 对象并返回

            • return applyLifecycleMethods(FORWARD_URL_PREFIX, view):Spring 中的初始化操作

            重定向:实例为 RedirectView

            • if (viewName.startsWith(REDIRECT_URL_PREFIX)):视图名字是否是 redirect: 的前缀
            • redirectUrl = viewName.substring(REDIRECT_URL_PREFIX.length()):名字截取前缀
            • RedirectView view = new RedirectView():新建 RedirectView 对象并返回
      • bestView = getBestView(candidateViews, requestedMediaTypes, attrs):选出最佳匹配的视图对象

  • view.render(mv.getModelInternal(), request, response)页面渲染

    • mergedModel = createMergedOutputModel(model, request, response):把请求域中的数据封装到 model

    • prepareResponse(request, response):响应前的准备工作,设置一些响应头

    • renderMergedOutputModel(mergedModel, getRequestToExpose(request), response):渲染输出的数据

      getRequestToExpose(request):获取 Servlet 原生的方式

      请求转发 InternalResourceView 的逻辑:请求域中的数据不丢失

      • exposeModelAsRequestAttributes(model, request):暴露 model 作为请求域的属性
        • model.forEach():遍历 Model 中的数据
        • request.setAttribute(name, value)设置到请求域中
      • exposeHelpers(request):自定义接口
      • dispatcherPath = prepareForRendering(request, response):确定调度分派的路径,此例是 /success
      • rd = getRequestDispatcher(request, dispatcherPath)获取 Servlet 原生的 RequestDispatcher 实现转发
      • rd.forward(request, response):实现请求转发

      重定向 RedirectView 的逻辑:请求域中的数据会丢失

      • targetUrl = createTargetUrl(model, request):获取目标 URL
        • enc = request.getCharacterEncoding():设置编码 UTF-8
        • appendQueryProperties(targetUrl, model, enc):添加一些属性,比如 url + ?name=123&&age=324
      • sendRedirect(request, response, targetUrl, this.http10Compatible):重定向
        • response.sendRedirect(encodedURL)使用 Servlet 原生方法实现重定向

异步调用

请求参数

名称:@RequestBody

类型:形参注解

位置:处理器类中的方法形参前方

作用:将异步提交数据转换成标准请求参数格式,并赋值给形参
范例:

1
2
3
4
5
6
7
8
@Controller //控制层
public class AjaxController {
@RequestMapping("/ajaxController")
public String ajaxController(@RequestBody String message){
System.out.println(message);
return "page.jsp";
}
}
  • 注解添加到 POJO 参数前方时,封装的异步提交数据按照 POJO 的属性格式进行关系映射
    • POJO 中的属性如果请求数据中没有,属性值为 null
    • POJO 中没有的属性如果请求数据中有,不进行映射
  • 注解添加到集合参数前方时,封装的异步提交数据按照集合的存储结构进行关系映射
1
2
3
4
5
6
7
8
9
10
11
12
13
@RequestMapping("/ajaxPojoToController")
//如果处理参数是POJO,且页面发送的请求数据格式与POJO中的属性对应,@RequestBody注解可以自动映射对应请求数据到POJO中
public String ajaxPojoToController(@RequestBody User user){
System.out.println("controller pojo :"+user);
return "page.jsp";
}

@RequestMapping("/ajaxListToController")
//如果处理参数是List集合且封装了POJO,且页面发送的数据是JSON格式,数据将自动映射到集合参数
public String ajaxListToController(@RequestBody List<User> userList){
System.out.println("controller list :"+userList);
return "page.jsp";
}

ajax.jsp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<%@page pageEncoding="UTF-8" language="java" contentType="text/html;UTF-8" %>

<a href="javascript:void(0);" id="testAjax">访问springmvc后台controller</a><br/>
<a href="javascript:void(0);" id="testAjaxPojo">传递Json格式POJO</a><br/>
<a href="javascript:void(0);" id="testAjaxList">传递Json格式List</a><br/>

<script type="text/javascript" src="${pageContext.request.contextPath}/js/jquery-3.3.1.min.js"></script>
<script type="text/javascript">
$(function () {
//为id="testAjax"的组件绑定点击事件
$("#testAjax").click(function(){
//发送异步调用
$.ajax({
//请求方式:POST请求
type:"POST",
//请求的地址
url:"ajaxController",
//请求参数(也就是请求内容)
data:'ajax message',
//响应正文类型
dataType:"text",
//请求正文的MIME类型
contentType:"application/text",
});
});

//为id="testAjaxPojo"的组件绑定点击事件
$("#testAjaxPojo").click(function(){
$.ajax({
type:"POST",
url:"ajaxPojoToController",
data:'{"name":"Jock","age":39}',
dataType:"text",
contentType:"application/json",
});
});

//为id="testAjaxList"的组件绑定点击事件
$("#testAjaxList").click(function(){
$.ajax({//.....
data:'[{"name":"Jock","age":39},{"name":"Jockme","age":40}]'})}
}
</script>

web.xml配置:请求响应章节请求中的web.xml配置

1
CharacterEncodingFilter + DispatcherServlet

spring-mvc.xml:

1
2
3
<context:component-scan base-package="controller,domain"/>
<mvc:resources mapping="/js/**" location="/js/"/>
<mvc:annotation-driven/>

响应数据

注解:@ResponseBody

作用:将 Java 对象转为 json 格式的数据

方法返回值为 POJO 时,自动封装数据成 Json 对象数据:

1
2
3
4
5
6
7
@RequestMapping("/ajaxReturnJson")
@ResponseBody
public User ajaxReturnJson(){
System.out.println("controller return json pojo...");
User user = new User("Jockme",40);
return user;
}

方法返回值为 List 时,自动封装数据成 json 对象数组数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
@RequestMapping("/ajaxReturnJsonList")
@ResponseBody
//基于jackon技术,使用@ResponseBody注解可以将返回的保存POJO对象的集合转成json数组格式数据
public List ajaxReturnJsonList(){
System.out.println("controller return json list...");
User user1 = new User("Tom",3);
User user2 = new User("Jerry",5);

ArrayList al = new ArrayList();
al.add(user1);
al.add(user2);
return al;
}

AJAX 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
//为id="testAjaxReturnString"的组件绑定点击事件
$("#testAjaxReturnString").click(function(){
//发送异步调用
$.ajax({
type:"POST",
url:"ajaxReturnString",
//回调函数
success:function(data){
//打印返回结果
alert(data);
}
});
});

//为id="testAjaxReturnJson"的组件绑定点击事件
$("#testAjaxReturnJson").click(function(){
$.ajax({
type:"POST",
url:"ajaxReturnJson",
success:function(data){
alert(data['name']+" , "+data['age']);
}
});
});

//为id="testAjaxReturnJsonList"的组件绑定点击事件
$("#testAjaxReturnJsonList").click(function(){
$.ajax({
type:"POST",
url:"ajaxReturnJsonList",
success:function(data){
alert(data);
alert(data[0]["name"]);
alert(data[1]["age"]);
}
});
});

跨域访问

跨域访问:当通过域名 A 下的操作访问域名 B 下的资源时,称为跨域访问,跨域访问时,会出现无法访问的现象

环境搭建:

  • 为当前主机添加备用域名
    • 修改 windows 安装目录中的 host 文件
    • 格式: ip 域名
  • 动态刷新 DNS
    • 命令: ipconfig /displaydns
    • 命令: ipconfig /flushdns

跨域访问支持:

  • 名称:@CrossOrigin
  • 类型:方法注解 、 类注解
  • 位置:处理器类中的方法上方或类上方
  • 作用:设置当前处理器方法 / 处理器类中所有方法支持跨域访问
  • 范例:
1
2
3
4
5
6
7
8
9
10
11
@RequestMapping("/cross")
@ResponseBody
//使用@CrossOrigin开启跨域访问
//标注在处理器方法上方表示该方法支持跨域访问
//标注在处理器类上方表示该处理器类中的所有处理器方法均支持跨域访问
@CrossOrigin
public User cross(HttpServletRequest request){
System.out.println("controller cross..." + request.getRequestURL());
User user = new User("Jockme",36);
return user;
}
  • jsp 文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<a href="javascript:void(0);" id="testCross">跨域访问</a><br/>
<script type="text/javascript" src="${pageContext.request.contextPath}/js/jquery-3.3.1.min.js"></script>
<script type="text/javascript">
$(function () {
//为id="testCross"的组件绑定点击事件
$("#testCross").click(function(){
//发送异步调用
$.ajax({
type:"POST",
url:"http://127.0.0.1/cross",
//回调函数
success:function(data){
alert("跨域调用信息反馈:" + data['name'] + "," + data['age']);
}
});
});
});
</script>

拦截器

基本介绍

拦截器(Interceptor)是一种动态拦截方法调用的机制

作用:

  1. 在指定的方法调用前后执行预先设定后的的代码
  2. 阻止原始方法的执行

核心原理:AOP 思想

拦截器链:多个拦截器按照一定的顺序,对原始被调用功能进行增强

拦截器和过滤器对比:

  1. 归属不同: Filter 属于 Servlet 技术, Interceptor 属于 SpringMVC 技术

  2. 拦截内容不同: Filter 对所有访问进行增强, Interceptor 仅针对 SpringMVC 的访问进行增强


处理方法

前置处理

原始方法之前运行:

1
2
3
4
5
6
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
System.out.println("preHandle");
return true;
}
  • 参数:
    • request:请求对象
    • response:响应对象
    • handler:被调用的处理器对象,本质上是一个方法对象,对反射中的Method对象进行了再包装
      • handler:public String controller.InterceptorController.handleRun
      • handler.getClass():org.springframework.web.method.HandlerMethod
  • 返回值:
    • 返回值为 false,被拦截的处理器将不执行

后置处理

原始方法运行后运行,如果原始方法被拦截,则不执行:

1
2
3
4
5
6
public void postHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler,
ModelAndView modelAndView) throws Exception {
System.out.println("postHandle");
}

参数:

  • modelAndView:如果处理器执行完成具有返回结果,可以读取到对应数据与页面信息,并进行调整

异常处理

拦截器最后执行的方法,无论原始方法是否执行:

1
2
3
4
5
6
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) throws Exception {
System.out.println("afterCompletion");
}

参数:

  • ex:如果处理器执行过程中出现异常对象,可以针对异常情况进行单独处理

拦截配置

拦截路径:

  • /**:表示拦截所有映射
  • /* :表示拦截所有/开头的映射
  • /user/*:表示拦截所有 /user/ 开头的映射
  • /user/add*:表示拦截所有 /user/ 开头,且具体映射名称以 add 开头的映射
  • /user/*All:表示拦截所有 /user/ 开头,且具体映射名称以 All 结尾的映射
1
2
3
4
5
6
7
8
9
10
11
<mvc:interceptors>
<!--开启具体的拦截器的使用,可以配置多个-->
<mvc:interceptor>
<!--设置拦截器的拦截路径,支持*通配-->
<mvc:mapping path="/handleRun*"/>
<!--设置拦截排除的路径,配置/**或/*,达到快速配置的目的-->
<mvc:exclude-mapping path="/b*"/>
<!--指定具体的拦截器类-->
<bean class="MyInterceptor"/>
</mvc:interceptor>
</mvc:interceptors>

拦截器链

责任链模式:责任链模式是一种行为模式

特点:沿着一条预先设定的任务链顺序执行,每个节点具有独立的工作任务
优势:

  • 独立性:只关注当前节点的任务,对其他任务直接放行到下一节点
  • 隔离性:具备链式传递特征,无需知晓整体链路结构,只需等待请求到达后进行处理即可
  • 灵活性:可以任意修改链路结构动态新增或删减整体链路责任
  • 解耦:将动态任务与原始任务解耦

缺点:

  • 链路过长时,处理效率低下
  • 可能存在节点上的循环引用现象,造成死循环,导致系统崩溃

源码解析

DispatcherServlet#doDispatch 方法中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
try {
// 获取映射器以及映射器的所有拦截器(运行原理部分详解了源码)
mappedHandler = getHandler(processedRequest);
// 前置处理,返回 false 代表条件成立
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
//请求从这里直接结束
return;
}
//所有拦截器都返回 true,执行目标方法
mv = ha.handle(processedRequest, response, mappedHandler.getHandler())
// 倒序执行所有拦截器的后置处理方法
mappedHandler.applyPostHandle(processedRequest, response, mv);
} catch (Exception ex) {
//异常处理机制
triggerAfterCompletion(processedRequest, response, mappedHandler, ex);
}
}

HandlerExecutionChain#applyPreHandle:前置处理

1
2
3
4
5
6
7
8
9
10
11
12
13
boolean applyPreHandle(HttpServletRequest request, HttpServletResponse response) throws Exception {
//遍历所有的拦截器
for (int i = 0; i < this.interceptorList.size(); i++) {
HandlerInterceptor interceptor = this.interceptorList.get(i);
//执行前置处理,如果拦截器返回 false,则条件成立,不在执行其他的拦截器,直接返回 false,请求直接结束
if (!interceptor.preHandle(request, response, this.handler)) {
triggerAfterCompletion(request, response, null);
return false;
}
this.interceptorIndex = i;
}
return true;
}

HandlerExecutionChain#applyPostHandle:后置处理

1
2
3
4
5
6
7
8
void applyPostHandle(HttpServletRequest request, HttpServletResponse response, @Nullable ModelAndView mv)
throws Exception {
//倒序遍历
for (int i = this.interceptorList.size() - 1; i >= 0; i--) {
HandlerInterceptor interceptor = this.interceptorList.get(i);
interceptor.postHandle(request, response, this.handler, mv);
}
}

DispatcherServlet#triggerAfterCompletion 底层调用 HandlerExecutionChain#triggerAfterCompletion:

  • 前面的步骤有任何异常都会直接倒序触发 afterCompletion

  • 页面成功渲染有异常,也会倒序触发 afterCompletion

1
2
3
4
5
6
7
8
9
10
11
12
13
void triggerAfterCompletion(HttpServletRequest request, HttpServletResponse response, @Nullable Exception ex) {
//倒序遍历
for (int i = this.interceptorIndex; i >= 0; i--) {
HandlerInterceptor interceptor = this.interceptorList.get(i);
try {
//执行异常处理的方法
interceptor.afterCompletion(request, response, this.handler, ex);
}
catch (Throwable ex2) {
logger.error("HandlerInterceptor.afterCompletion threw exception", ex2);
}
}
}

拦截器的执行流程:

参考文章:https://www.yuque.com/atguigu/springboot/vgzmgh#wtPLU


自定义

  • Contoller层

    1
    2
    3
    4
    5
    6
    7
    8
    @Controller
    public class InterceptorController {
    @RequestMapping("/handleRun")
    public String handleRun() {
    System.out.println("业务处理器运行------------main");
    return "page.jsp";
    }
    }
  • 自定义拦截器需要实现 HandleInterceptor 接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    //自定义拦截器需要实现HandleInterceptor接口
    public class MyInterceptor implements HandlerInterceptor {
    //处理器运行之前执行
    @Override
    public boolean preHandle(HttpServletRequest request,
    HttpServletResponse response,
    Object handler) throws Exception {
    System.out.println("前置运行----a1");
    //返回值为false将拦截原始处理器的运行
    //如果配置多拦截器,返回值为false将终止当前拦截器后面配置的拦截器的运行
    return true;
    }

    //处理器运行之后执行
    @Override
    public void postHandle(HttpServletRequest request,
    HttpServletResponse response,
    Object handler,
    ModelAndView modelAndView) throws Exception {
    System.out.println("后置运行----b1");
    }

    //所有拦截器的后置执行全部结束后,执行该操作
    @Override
    public void afterCompletion(HttpServletRequest request,
    HttpServletResponse response,
    Object handler,
    Exception ex) throws Exception {
    System.out.println("完成运行----c1");
    }
    }

    说明:三个方法的运行顺序为 preHandle → postHandle → afterCompletion,如果 preHandle 返回值为 false,三个方法仅运行preHandle

  • web.xml:

    1
    CharacterEncodingFilter + DispatcherServlet
  • 配置拦截器:spring-mvc.xml

    1
    2
    3
    4
    5
    6
    7
    8
    <mvc:annotation-driven/>
    <context:component-scan base-package="interceptor,controller"/>
    <mvc:interceptors>
    <mvc:interceptor>
    <mvc:mapping path="/handleRun"/>
    <bean class="interceptor.MyInterceptor"/>
    </mvc:interceptor>
    </mvc:interceptors>

    注意:配置顺序为先配置执行位置,后配置执行类


异常处理

处理器

异常处理器: HandlerExceptionResolver 接口

类继承该接口的以后,当开发出现异常后会执行指定的功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component
public class ExceptionResolver implements HandlerExceptionResolver {
@Override
public ModelAndView resolveException(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
System.out.println("异常处理器正在执行中");
ModelAndView modelAndView = new ModelAndView();
//定义异常现象出现后,反馈给用户查看的信息
modelAndView.addObject("msg","出错啦! ");
//定义异常现象出现后,反馈给用户查看的页面
modelAndView.setViewName("error.jsp");
return modelAndView;
}
}

根据异常的种类不同,进行分门别类的管理,返回不同的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ExceptionResolver implements HandlerExceptionResolver {
@Override
public ModelAndView resolveException(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
System.out.println("my exception is running ...." + ex);
ModelAndView modelAndView = new ModelAndView();
if( ex instanceof NullPointerException){
modelAndView.addObject("msg","空指针异常");
}else if ( ex instanceof ArithmeticException){
modelAndView.addObject("msg","算数运算异常");
}else{
modelAndView.addObject("msg","未知的异常");
}
modelAndView.setViewName("error.jsp");
return modelAndView;
}
}

模拟错误:

1
2
3
4
5
6
7
8
9
10
11
12
@Controller
public class UserController {
@RequestMapping("/save")
@ResponseBody
public String save(@RequestBody String name) {
//模拟业务层发起调用产生了异常
// int i = 1/0;
// String str = null;
// str.length();

return "error.jsp";
}

注解开发

使用注解实现异常分类管理,开发异常处理器

@ControllerAdvice 注解:

  • 类型:类注解

  • 位置:异常处理器类上方

  • 作用:设置当前类为异常处理器类

  • 格式:

    1
    2
    3
    4
    5
    @Component
    //声明该类是一个Controller的通知类,声明后该类就会被加载成异常处理器
    @ControllerAdvice
    public class ExceptionAdvice {
    }

@ExceptionHandler 注解:

  • 类型:方法注解

  • 位置:异常处理器类中针对指定异常进行处理的方法上方

  • 作用:设置指定异常的处理方式

  • 说明:处理器方法可以设定多个

  • 格式:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    @Component
    @ControllerAdvice
    public class ExceptionAdvice {
    //类中定义的方法携带@ExceptionHandler注解的会被作为异常处理器,后面添加实际处理的异常类型
    @ExceptionHandler(NullPointerException.class)
    @ResponseBody
    public String doNullException(Exception ex){
    return "空指针异常";
    }

    @ExceptionHandler(Exception.class)
    @ResponseBody
    public String doException(Exception ex){
    return "all Exception";
    }
    }

@ResponseStatus 注解:

  • 类型:类注解、方法注解

  • 位置:异常处理器类、方法上方

  • 参数:

    value:出现错误指定返回状态码

    reason:出现错误返回的错误信息


解决方案

  • web.xml

    1
    DispatcherServlet + CharacterEncodingFilter
  • ajax.jsp

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    <%@page pageEncoding="UTF-8" language="java" contentType="text/html;UTF-8" %>

    <a href="javascript:void(0);" id="testException">点击</a><br/>

    <script type="text/javascript" src="${pageContext.request.contextPath}/js/jquery-3.3.1.min.js"></script>
    <script type="text/javascript">
    $(function () {
    $("#testException").click(function(){
    $.ajax({
    contentType:"application/json",
    type:"POST",
    url:"save",
    /*通过修改参数,激活自定义异常的出现*/
    // name长度低于8位出现业务异常
    // age小于0出现业务异常
    // age大于100出现系统异常
    // age类型如果无法匹配将转入其他类别异常
    data:'{"name":"JockSuperMan","age":"-1"}',
    dataType:"text",
    //回调函数
    success:function(data){
    alert(data);
    }
    });
    });
    });
    </script>
  • spring-mvc.xml

    1
    2
    3
    <mvc:annotation-driven/>
    <context:component-scan base-package="com.seazean"/>
    <mvc:resources mapping="/js/**" location="/js/"/>
  • java / controller / UserController

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    @Controller
    public class UserController {
    @RequestMapping("/save")
    @ResponseBody
    public List<User> save(@RequestBody User user) {
    System.out.println("user controller save is running ...");
    //对用户的非法操作进行判定,并包装成异常对象进行处理,便于统一管理
    if(user.getName().trim().length() < 8){
    throw new BusinessException("对不起,用户名长度不满足要求,请重新输入!");
    }
    if(user.getAge() < 0){
    throw new BusinessException("对不起,年龄必须是0到100之间的数字!");
    }
    if(user.getAge() > 100){
    throw new SystemException("服务器连接失败,请尽快检查处理!");
    }

    User u1 = new User("Tom",3);
    User u2 = new User("Jerry",5);
    ArrayList<User> al = new ArrayList<User>();
    al.add(u1);al.add(u2);
    return al;
    }
    }
  • 自定义异常

    1
    2
    //自定义异常继承RuntimeException,覆盖父类所有的构造方法
    public class BusinessException extends RuntimeException {覆盖父类所有的构造方法}
    1
    public class SystemException extends RuntimeException {}
  • 通过自定义异常将所有的异常现象进行分类管理,以统一的格式对外呈现异常消息

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    @Component
    @ControllerAdvice
    public class ProjectExceptionAdvice {
    @ExceptionHandler(BusinessException.class)
    public String doBusinessException(Exception ex, Model m){
    //使用参数Model将要保存的数据传递到页面上,功能等同于ModelAndView
    //业务异常出现的消息要发送给用户查看
    m.addAttribute("msg",ex.getMessage());
    return "error.jsp";
    }

    @ExceptionHandler(SystemException.class)
    public String doSystemException(Exception ex, Model m){
    //系统异常出现的消息不要发送给用户查看,发送统一的信息给用户看
    m.addAttribute("msg","服务器出现问题,请联系管理员!");
    return "error.jsp";
    }

    @ExceptionHandler(Exception.class)
    public String doException(Exception ex, Model m){
    m.addAttribute("msg",ex.getMessage());
    //将ex对象保存起来
    return "error.jsp";
    }

    }

文件传输

上传下载

上传文件过程:

MultipartResolver接口:

  • MultipartResolver 接口定义了文件上传过程中的相关操作,并对通用性操作进行了封装
  • MultipartResolver 接口底层实现类 CommonsMultipartResovler
  • CommonsMultipartResovler 并未自主实现文件上传下载对应的功能,而是调用了 apache 文件上传下载组件

文件上传下载实现:

  • 导入坐标

    1
    2
    3
    4
    5
    <dependency>
    <groupId>commons-fileupload</groupId>
    <artifactId>commons-fileupload</artifactId>
    <version>1.4</version>
    </dependency>
  • 页面表单 fileupload.jsp

    1
    2
    3
    4
    <form method="post" action="/upload" enctype="multipart/form-data">
    <input type="file" name="file"><br>
    <input type="submit" value="提交">
    </form>
  • web.xml

    1
    DispatcherServlet + CharacterEncodingFilter
  • 控制器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @PostMapping("/upload")
    public String upload(@RequestParam("email") String email,
    @RequestParam("username") String username,
    @RequestPart("headerImg") MultipartFile headerImg) throws IOException {

    if(!headerImg.isEmpty()){
    //保存到文件服务器,OSS服务器
    String originalFilename = headerImg.getOriginalFilename();
    headerImg.transferTo(new File("H:\\cache\\" + originalFilename));
    }
    return "main";
    }

名称问题

MultipartFile 参数中封装了上传的文件的相关信息。

  1. 文件命名问题, 获取上传文件名,并解析文件名与扩展名

    1
    file.getOriginalFilename();
  2. 文件名过长问题

  3. 文件保存路径

    1
    2
    3
    4
    ServletContext context = request.getServletContext();
    String realPath = context.getRealPath("/uploads");
    File file = new File(realPath + "/");
    if(!file.exists()) file.mkdirs();
  4. 重名问题

    1
    String uuid = UUID.randomUUID.toString().replace("-", "").toUpperCase();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Controller
public class FileUploadController {
@RequestMapping(value = "/fileupload")
//参数中定义MultipartFile参数,用于接收页面提交的type=file类型的表单,表单名称与参数名相同
public String fileupload(MultipartFile file,MultipartFile file1,MultipartFile file2, HttpServletRequest request) throws IOException {
System.out.println("file upload is running ..."+file);
// MultipartFile参数中封装了上传的文件的相关信息
// System.out.println(file.getSize());
// System.out.println(file.getBytes().length);
// System.out.println(file.getContentType());
// System.out.println(file.getName());
// System.out.println(file.getOriginalFilename());
// System.out.println(file.isEmpty());
//首先判断是否是空文件,也就是存储空间占用为0的文件
if(!file.isEmpty()){
//如果大小在范围要求内正常处理,否则抛出自定义异常告知用户(未实现)
//获取原始上传的文件名,可以作为当前文件的真实名称保存到数据库中备用
String fileName = file.getOriginalFilename();
//设置保存的路径
String realPath = request.getServletContext().getRealPath("/images");
//保存文件的方法,通常文件名使用随机生成策略产生,避免文件名冲突问题
file.transferTo(new File(realPath,file.getOriginalFilename()));
}
//测试一次性上传多个文件
if(!file1.isEmpty()){
String fileName = file1.getOriginalFilename();
//可以根据需要,对不同种类的文件做不同的存储路径的区分,修改对应的保存位置即可
String realPath = request.getServletContext().getRealPath("/images");
file1.transferTo(new File(realPath,file1.getOriginalFilename()));
}
if(!file2.isEmpty()){
String fileName = file2.getOriginalFilename();
String realPath = request.getServletContext().getRealPath("/images");
file2.transferTo(new File(realPath,file2.getOriginalFilename()));
}
return "page.jsp";
}
}

源码解析

StandardServletMultipartResolver 是文件上传解析器

DispatcherServlet#doDispatch:

1
2
3
4
5
6
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
// 判断当前请求是不是文件上传请求
processedRequest = checkMultipart(request);
// 文件上传请求会对 request 进行包装,导致两者不相等,此处赋值为 true,代表已经被解析
multipartRequestParsed = (processedRequest != request);
}

DispatcherServlet#checkMultipart:

  • if (this.multipartResolver != null && this.multipartResolver.isMultipart(request)):判断是否是文件请求
    • StandardServletMultipartResolver#isMultipart:根据开头是否符合 multipart/form-data 或者 multipart/
  • return this.multipartResolver.resolveMultipart(request):把请求封装成 StandardMultipartHttpServletRequest 对象

开始执行 ha.handle() 目标方法进行数据的解析

  • RequestPartMethodArgumentResolver#supportsParameter:支持解析文件上传数据

    1
    2
    3
    4
    5
    6
    public boolean supportsParameter(MethodParameter parameter) {
    // 参数上有 @RequestPart 注解
    if (parameter.hasParameterAnnotation(RequestPart.class)) {
    return true;
    }
    }
  • RequestPartMethodArgumentResolver#resolveArgument:解析参数数据,封装成 MultipartFile 对象

    • RequestPart requestPart = parameter.getParameterAnnotation(RequestPart.class):获取注解的相关信息
    • String name = getPartName(parameter, requestPart):获取上传文件的名字
    • Object mpArg = MultipartResolutionDelegate.resolveMultipartArgument():解析参数
      • List<MultipartFile> files = multipartRequest.getFiles(name):获取文件的所有数据
  • return doInvoke(args):解析完成执行自定义的方法,完成上传功能


实用技术

校验框架

校验概述

表单校验保障了数据有效性、安全性

校验分类:客户端校验和服务端校验

  • 格式校验
    • 客户端:使用 js 技术,利用正则表达式校验
    • 服务端:使用校验框架
  • 逻辑校验
    • 客户端:使用ajax发送要校验的数据,在服务端完成逻辑校验,返回校验结果
    • 服务端:接收到完整的请求后,在执行业务操作前,完成逻辑校验

表单校验框架:

  • JSR(Java Specification Requests):Java 规范提案

  • 303:提供bean属性相关校验规则

  • JCP(Java Community Process):Java社区

  • Hibernate框架中包含一套独立的校验框架hibernate-validator

  • 导入坐标:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <!--导入校验的jsr303规范-->
    <dependency>
    <groupId>javax.validation</groupId>
    <artifactId>validation-api</artifactId>
    <version>2.0.1.Final</version>
    </dependency>
    <!--导入校验框架实现技术-->
    <dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.1.0.Final</version>
    </dependency>

注意:

  • tomcat7:搭配 hibernate-validator 版本 5...Final
  • tomcat8.5:搭配 hibernate-validator 版本 6...Final

基本使用

开启校验

名称:@Valid、@Validated

类型:形参注解

位置:处理器类中的实体类类型的方法形参前方

作用:设定对当前实体类类型参数进行校验

范例:

1
2
3
4
@RequestMapping(value = "/addemployee")
public String addEmployee(@Valid Employee employee) {
System.out.println(employee);
}
校验规则

名称:@NotNull

类型:属性注解等

位置:实体类属性上方

作用:设定当前属性校验规则

范例:每个校验规则所携带的参数不同,根据校验规则进行相应的调整,具体的校验规则查看对应的校验框架进行获取

1
2
3
4
public class Employee{
@NotNull(message = "姓名不能为空")
private String name;//员工姓名
}
错误信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RequestMapping(value = "/addemployee")
//Errors对象用于封装校验结果,如果不满足校验规则,对应的校验结果封装到该对象中,包含校验的属性名和校验不通过返回的消息
public String addEmployee(@Valid Employee employee, Errors errors, Model model){
System.out.println(employee);
//判定Errors对象中是否存在未通过校验的字段
if(errors.hasErrors()){
for(FieldError error : errors.getFieldErrors()){
//将校验结果添加到Model对象中,用于页面显示,返回json数据即可
model.addAttribute(error.getField(),error.getDefaultMessage());
}
//当出现未通过校验的字段时,跳转页面到原始页面,进行数据回显
return "addemployee.jsp";
}
return "success.jsp";
}

通过形参Errors获取校验结果数据,通过Model接口将数据封装后传递到页面显示,页面获取后台封装的校验结果信息

1
2
3
4
5
<form action="/addemployee" method="post">
员工姓名:<input type="text" name="name"><span style="color:red">${name}</span><br/>
员工年龄:<input type="text" name="age"><span style="color:red">${age}</span><br/>
<input type="submit" value="提交">
</form>

多规则校验

  • 同一个属性可以添加多个校验器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public class Employee{
    @NotBlank(message = "姓名不能为空")
    private String name;//员工姓名

    @NotNull(message = "请输入年龄")
    @Max(value = 60,message = "年龄最大值60")
    @Min(value = 18,message = "年龄最小值18")
    private Integer age;//员工年龄
    }
  • 三种判定空校验器的区别


嵌套校验

名称:@Valid

类型:属性注解

位置:实体类中的引用类型属性上方

作用:设定当前应用类型属性中的属性开启校验

范例:

1
2
3
4
5
public class Employee {
//实体类中的引用类型通过标注@Valid注解,设定开启当前引用类型字段中的属性参与校验
@Valid
private Address address;
}

注意:开启嵌套校验后,被校验对象内部需要添加对应的校验规则

1
2
3
4
5
6
7
8
9
//嵌套校验的实体中,对每个属性正常添加校验规则即可
public class Address implements Serializable {
@NotBlank(message = "请输入省份名称")
private String provinceName;//省份名称

@NotBlank(message = "请输入邮政编码")
@Size(max = 6,min = 6,message = "邮政编码由6位组成")
private String zipCode;//邮政编码
}

分组校验

分组校验的介绍

  • 同一个模块,根据执行的业务不同,需要校验的属性会有不同
    • 新增用户
    • 修改用户
  • 对不同种类的属性进行分组,在校验时可以指定参与校验的字段所属的组类别
    • 定义组(通用)
    • 为属性设置所属组,可以设置多个
    • 开启组校验

domain:

1
2
3
//用于设定分组校验中的组名,当前接口仅提供字节码,用于识别
public interface GroupOne {
}
1
2
3
4
5
6
7
8
9
10
11
12
13
public class Employee{
@NotBlank(message = "姓名不能为空",groups = {GroupA.class})
private String name;//员工姓名

@NotNull(message = "请输入年龄",groups = {GroupA.class})
@Max(value = 60,message = "年龄最大值60")//不加Group的校验不生效
@Min(value = 18,message = "年龄最小值18")
private Integer age;//员工年龄

@Valid
private Address address;
//......
}

controller:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Controller
public class EmployeeController {
@RequestMapping(value = "/addemployee")
public String addEmployee(@Validated({GroupA.class}) Employee employee, Errors errors, Model m){
if(errors.hasErrors()){
List<FieldError> fieldErrors = errors.getFieldErrors();
System.out.println(fieldErrors.size());
for(FieldError error : fieldErrors){
m.addAttribute(error.getField(),error.getDefaultMessage());
}
return "addemployee.jsp";
}
return "success.jsp";
}
}

jsp:

1
2
3
4
5
6
7
<form action="/addemployee" method="post"><%--页面使用${}获取后台传递的校验信息--%>
员工姓名:<input type="text" name="name"><span style="color:red">${name}</span><br/>
员工年龄:<input type="text" name="age"><span style="color:red">${age}</span><br/>
<%--注意,引用类型的校验未通过信息不是通过对象进行封装的,直接使用对象名.属性名的格式作为整体属性字符串进行保存的,和使用者的属性传递方式有关,不具有通用性,仅适用于本案例--%>
省:<input type="text" name="address.provinceName"><span style="color:red">${requestScope['address.provinceName']}</span><br/>
<input type="submit" value="提交">
/form>

Lombok

Lombok 用标签方式代替构造器、getter/setter、toString() 等方法

引入依赖:

1
2
3
4
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>

下载插件:IDEA 中 File → Settings → Plugins,搜索安装 Lombok 插件

常用注解:

1
2
3
4
5
@NoArgsConstructor		// 无参构造
@AllArgsConstructor // 全参构造
@Data // set + get
@ToString // toString
@EqualsAndHashCode // hashConde + equals

简化日志:

1
2
3
4
5
6
7
8
9
@Slf4j
@RestController
public class HelloController {
@RequestMapping("/hello")
public String handle01(@RequestParam("name") String name){
log.info("请求进来了....");
return "Hello, Spring!" + "你好:" + name;
}
}

Boot

基本介绍

Boot介绍

SpringBoot 提供了一种快速使用 Spring 的方式,基于约定优于配置的思想,可以让开发人员不必在配置与逻辑业务之间进行思维的切换,全身心的投入到逻辑业务的代码编写中,从而大大提高了开发的效率

SpringBoot 功能:

  • 自动配置,自动配置是一个运行时(更准确地说,是应用程序启动时)的过程,考虑了众多因素选择使用哪个配置,该过程是SpringBoot 自动完成的

  • 起步依赖,起步依赖本质上是一个 Maven 项目对象模型(Project Object Model,POM),定义了对其他库的传递依赖,这些东西加在一起即支持某项功能。简单的说,起步依赖就是将具备某种功能的坐标打包到一起,并提供一些默认的功能

  • 辅助功能,提供了一些大型项目中常见的非功能性特性,如内嵌 web 服务器、安全、指标,健康检测、外部配置等

参考视频:https://www.bilibili.com/video/BV19K4y1L7MT


构建工程

普通构建:

  1. 创建 Maven 项目

  2. 导入 SpringBoot 起步依赖

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    <!--springboot 工程需要继承的父工程-->
    <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.1.8.RELEASE</version>
    </parent>

    <dependencies>
    <!--web 开发的起步依赖-->
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    </dependencies>
  3. 定义 Controller

    1
    2
    3
    4
    5
    6
    7
    @RestController
    public class HelloController {
    @RequestMapping("/hello")
    public String hello(){
    return " hello Spring Boot !";
    }
    }
  4. 编写引导类

    1
    2
    3
    4
    5
    6
    7
    // 引导类,SpringBoot项目的入口
    @SpringBootApplication
    public class HelloApplication {
    public static void main(String[] args) {
    SpringApplication.run(HelloApplication.class, args);
    }
    }

快速构建:


自动装配

依赖管理

在 spring-boot-starter-parent 中定义了各种技术的版本信息,组合了一套最优搭配的技术版本。在各种 starter 中,定义了完成该功能需要的坐标合集,其中大部分版本信息来自于父工程。工程继承 parent,引入 starter 后,通过依赖传递,就可以简单方便获得需要的 jar 包,并且不会存在版本冲突,自动版本仲裁机制


底层注解

SpringBoot

@SpringBootApplication:启动注解,实现 SpringBoot 的自动部署

  • 参数 scanBasePackages:可以指定扫描范围
  • 默认扫描当前引导类所在包及其子包

假如所在包为 com.example.springbootenable,扫描配置包 com.example.config 的信息,三种解决办法:

  1. 使用 @ComponentScan 扫描 com.example.config 包

  2. 使用 @Import 注解,加载类,这些类都会被 Spring 创建并放入 ioc 容器,默认组件的名字就是全类名

  3. 对 @Import 注解进行封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//1.@ComponentScan("com.example.config")
//2.@Import(UserConfig.class)
@EnableUser
@SpringBootApplication
public class SpringbootEnableApplication {

public static void main(String[] args) {
ConfigurableApplicationContext context = SpringApplication.run(SpringbootEnableApplication.class, args);
//获取Bean
Object user = context.getBean("user");
System.out.println(user);

}
}

UserConfig:

1
2
3
4
5
6
7
@Configuration
public class UserConfig {
@Bean
public User user() {
return new User();
}
}

EnableUser 注解类:

1
2
3
4
5
6
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(UserConfig.class)//@Import注解实现Bean的动态加载
public @interface EnableUser {
}

Configuration

@Configuration:设置当前类为 SpringBoot 的配置类

  • proxyBeanMethods = true:Full 全模式,每个 @Bean 方法被调用多少次返回的组件都是单实例的,默认值,类组件之间有依赖关系,方法会被调用得到之前单实例组件
  • proxyBeanMethods = false:Lite 轻量级模式,每个 @Bean 方法被调用多少次返回的组件都是新创建的,类组件之间无依赖关系用 Lite 模式加速容器启动过程
1
2
3
4
5
6
7
8
@Configuration(proxyBeanMethods = true)
public class MyConfig {
@Bean //给容器中添加组件。以方法名作为组件的 id。返回类型就是组件类型。返回的值,就是组件在容器中的实例
public User user(){
User user = new User("zhangsan", 18);
return user;
}
}

Condition

条件注解

Condition 是 Spring4.0 后引入的条件化配置接口,通过实现 Condition 接口可以完成有条件的加载相应的 Bean

注解:@Conditional

作用:条件装配,满足 Conditional 指定的条件则进行组件注入,加上方法或者类上,作用范围不同

使用:@Conditional 配合 Condition 的实现类(ClassCondition)进行使用

ConditionContext 类API:

方法 说明
ConfigurableListableBeanFactory getBeanFactory() 获取到 IOC 使用的 beanfactory
ClassLoader getClassLoader() 获取类加载器
Environment getEnvironment() 获取当前环境信息
BeanDefinitionRegistry getRegistry() 获取到 bean 定义的注册类
  • ClassCondition

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    public class ClassCondition implements Condition {
    /**
    * context 上下文对象。用于获取环境,IOC容器,ClassLoader对象
    * metadata 注解元对象。 可以用于获取注解定义的属性值
    */
    @Override
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {

    //1.需求: 导入Jedis坐标后创建Bean
    //思路:判断redis.clients.jedis.Jedis.class文件是否存在
    boolean flag = true;
    try {
    Class<?> cls = Class.forName("redis.clients.jedis.Jedis");
    } catch (ClassNotFoundException e) {
    flag = false;
    }
    return flag;
    }
    }
  • UserConfig

    1
    2
    3
    4
    5
    6
    7
    8
    @Configuration
    public class UserConfig {
    @Bean
    @Conditional(ClassCondition.class)
    public User user(){
    return new User();
    }
    }
  • 启动类:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @SpringBootApplication
    public class SpringbootConditionApplication {
    public static void main(String[] args) {
    //启动SpringBoot应用,返回Spring的IOC容器
    ConfigurableApplicationContext context = SpringApplication.run(SpringbootConditionApplication.class, args);

    Object user = context.getBean("user");
    System.out.println(user);
    }
    }

自定义注解

将类的判断定义为动态的,判断哪个字节码文件存在可以动态指定

  • 自定义条件注解类

    1
    2
    3
    4
    5
    6
    7
    @Target({ElementType.TYPE, ElementType.METHOD})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    @Conditional(ClassCondition.class)
    public @interface ConditionOnClass {
    String[] value();
    }
  • ClassCondition

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    public class ClassCondition implements Condition {
    @Override
    public boolean matches(ConditionContext conditionContext, AnnotatedTypeMetadata metadata) {

    //需求:通过注解属性值value指定坐标后创建bean
    Map<String, Object> map = metadata.getAnnotationAttributes
    (ConditionOnClass.class.getName());
    //map = {value={属性值}}
    //获取所有的
    String[] value = (String[]) map.get("value");

    boolean flag = true;
    try {
    for (String className : value) {
    Class<?> cls = Class.forName(className);
    }
    } catch (Exception e) {
    flag = false;
    }
    return flag;
    }
    }
  • UserConfig

    1
    2
    3
    4
    5
    6
    7
    8
    @Configuration
    public class UserConfig {
    @Bean
    @ConditionOnClass("com.alibaba.fastjson.JSON")//JSON加载了才注册 User 到容器
    public User user(){
    return new User();
    }
    }
  • 测试 User 对象的创建


常用注解

SpringBoot 提供的常用条件注解:

@ConditionalOnProperty:判断配置文件中是否有对应属性和值才初始化 Bean

1
2
3
4
5
6
7
8
@Configuration
public class UserConfig {
@Bean
@ConditionalOnProperty(name = "it", havingValue = "seazean")
public User user() {
return new User();
}
}
1
it=seazean

@ConditionalOnClass:判断环境中是否有对应类文件才初始化 Bean

@ConditionalOnMissingClass:判断环境中是否有对应类文件才初始化 Bean

@ConditionalOnMissingBean:判断环境中没有对应Bean才初始化 Bean


ImportRes

使用 bean.xml 文件生成配置 bean,如果需要继续复用 bean.xml,@ImportResource 导入配置文件即可

1
2
3
4
@ImportResource("classpath:beans.xml")
public class MyConfig {
//...
}
1
2
3
4
5
6
7
8
9
10
<beans ...>
<bean id="haha" class="com.lun.boot.bean.User">
<property name="name" value="zhangsan"></property>
<property name="age" value="18"></property>
</bean>

<bean id="hehe" class="com.lun.boot.bean.Pet">
<property name="name" value="tomcat"></property>
</bean>
</beans>

Properties

@ConfigurationProperties:读取到 properties 文件中的内容,并且封装到 JavaBean 中

配置文件:

1
2
mycar.brand=BYD
mycar.price=100000

JavaBean 类:

1
2
3
4
5
6
@Component	//导入到容器内
@ConfigurationProperties(prefix = "mycar")//代表配置文件的前缀
public class Car {
private String brand;
private Integer price;
}

源码解析

启动流程

应用启动:

1
2
3
4
5
6
7
@SpringBootApplication
public class BootApplication {
public static void main(String[] args) {
// 启动代码
SpringApplication.run(BootApplication.class, args);
}
}

SpringApplication 构造方法:

  • this.resourceLoader = resourceLoader:资源加载器,初始为 null

  • this.webApplicationType = WebApplicationType.deduceFromClasspath():判断当前应用的类型,是响应式还是 Web 类

  • this.bootstrapRegistryInitializers = getBootstrapRegistryInitializersFromSpringFactories()获取引导器

    • META-INF/spring.factories 文件中找 org.springframework.boot.Bootstrapper
    • 寻找的顺序:classpath → spring-beans → boot-devtools → springboot → boot-autoconfigure
  • setInitializers(getSpringFactoriesInstances(ApplicationContextInitializer.class))获取初始化器

    • META-INF/spring.factories 文件中找 org.springframework.context.ApplicationContextInitializer
  • setListeners((Collection) getSpringFactoriesInstances(ApplicationListener.class))获取监听器

    • META-INF/spring.factories 文件中找 org.springframework.context.ApplicationListener
  • this.mainApplicationClass = deduceMainApplicationClass():获取出 main 程序类

SpringApplication#run(String… args):创建 IOC 容器并实现了自动装配

  • StopWatch stopWatch = new StopWatch():停止监听器,监控整个应用的启停

  • stopWatch.start():记录应用的启动时间

  • bootstrapContext = createBootstrapContext()创建引导上下文环境

    • bootstrapContext = new DefaultBootstrapContext():创建默认的引导类环境
    • this.bootstrapRegistryInitializers.forEach():遍历所有的引导器调用 initialize 方法完成初始化设置
  • configureHeadlessProperty():让当前应用进入 headless 模式

  • listeners = getRunListeners(args)获取所有 RunListener(运行监听器)

    • META-INF/spring.factories 文件中找 org.springframework.boot.SpringApplicationRunListener
  • listeners.starting(bootstrapContext, this.mainApplicationClass):遍历所有的运行监听器调用 starting 方法

  • applicationArguments = new DefaultApplicationArguments(args):获取所有的命令行参数

  • environment = prepareEnvironment(listeners, bootstrapContext, applicationArguments)准备环境

    • environment = getOrCreateEnvironment():返回或创建基础环境信息对象

      • switch (this.webApplicationType)根据当前应用的类型创建环境
        • case SERVLET:Web 应用环境对应 ApplicationServletEnvironment
        • case REACTIVE:响应式编程对应 ApplicationReactiveWebEnvironment
        • default:默认为 Spring 环境 ApplicationEnvironment
    • configureEnvironment(environment, applicationArguments.getSourceArgs()):读取所有配置源的属性值配置环境

    • ConfigurationPropertySources.attach(environment):属性值绑定环境信息

      • sources.addFirst(ATTACHED_PROPERTY_SOURCE_NAME,..):把 configurationProperties 放入环境的属性信息头部
    • listeners.environmentPrepared(bootstrapContext, environment):运行监听器调用 environmentPrepared(),EventPublishingRunListener 发布事件通知所有的监听器当前环境准备完成

    • DefaultPropertiesPropertySource.moveToEnd(environment):移动 defaultProperties 属性源到环境中的最后一个源

    • bindToSpringApplication(environment):与容器绑定当前环境

    • ConfigurationPropertySources.attach(environment):重新将属性值绑定环境信息

    • sources.remove(ATTACHED_PROPERTY_SOURCE_NAME):从环境信息中移除 configurationProperties

    • sources.addFirst(ATTACHED_PROPERTY_SOURCE_NAME,..):把 configurationProperties 重新放入环境信息

  • configureIgnoreBeanInfo(environment)配置忽略的 bean

  • printedBanner = printBanner(environment):打印 SpringBoot 标志

  • context = createApplicationContext()创建 IOC 容器

    switch (this.webApplicationType):根据当前应用的类型创建 IOC 容器

    • case SERVLET:Web 应用环境对应 AnnotationConfigServletWebServerApplicationContext
    • case REACTIVE:响应式编程对应 AnnotationConfigReactiveWebServerApplicationContext
    • default:默认为 Spring 环境 AnnotationConfigApplicationContext
  • context.setApplicationStartup(this.applicationStartup):设置一个启动器

  • prepareContext():配置 IOC 容器的基本信息

    • postProcessApplicationContext(context):后置处理流程

    • applyInitializers(context):获取所有的初始化器调用 initialize() 方法进行初始化

    • listeners.contextPrepared(context):所有的运行监听器调用 environmentPrepared() 方法,EventPublishingRunListener 发布事件通知 IOC 容器准备完成

    • listeners.contextLoaded(context):所有的运行监听器调用 contextLoaded() 方法,通知 IOC 加载完成

  • refreshContext(context)刷新 IOC 容器

    • Spring 的容器启动流程
    • invokeBeanFactoryPostProcessors(beanFactory)实现了自动装配
    • onRefresh()创建 WebServer 使用该接口
  • afterRefresh(context, applicationArguments):留给用户自定义容器刷新完成后的处理逻辑

  • stopWatch.stop():记录应用启动完成的时间

  • callRunners(context, applicationArguments):调用所有 runners

  • listeners.started(context):所有的运行监听器调用 started() 方法

  • listeners.running(context):所有的运行监听器调用 running() 方法

    • 获取容器中的 ApplicationRunner、CommandLineRunner

    • AnnotationAwareOrderComparator.sort(runners):合并所有 runner 并且按照 @Order 进行排序

    • callRunner():遍历所有的 runner,调用 run 方法

  • handleRunFailure(context, ex, listeners)处理异常,出现异常进入该逻辑

    • handleExitCode(context, exception):处理错误代码
    • listeners.failed(context, exception):运行监听器调用 failed() 方法
    • reportFailure(getExceptionReporters(context), exception):通知异常

注解分析

SpringBoot 定义了一套接口规范,这套规范规定 SpringBoot 在启动时会扫描外部引用 jar 包中的 META-INF/spring.factories 文件,将文件中配置的类型信息加载到 Spring 容器,并执行类中定义的各种操作,对于外部的 jar 包,直接引入一个 starter 即可

@SpringBootApplication 注解是 @SpringBootConfiguration@EnableAutoConfiguration@ComponentScan 注解的集合

  • @SpringBootApplication 注解

    1
    2
    3
    4
    5
    6
    7
    @Inherited
    @SpringBootConfiguration //代表 @SpringBootApplication 拥有了该注解的功能
    @EnableAutoConfiguration //同理
    @ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
    @Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
    // 扫描被 @Component (@Service,@Controller)注解的 bean,容器中将排除TypeExcludeFilter 和 AutoConfigurationExcludeFilter
    public @interface SpringBootApplication { }
  • @SpringBootConfiguration 注解:

    1
    2
    3
    4
    5
    6
    @Configuration	// 代表是配置类
    @Indexed
    public @interface SpringBootConfiguration {
    @AliasFor(annotation = Configuration.class)
    boolean proxyBeanMethods() default true;
    }

    @AliasFor 注解:表示别名,可以注解到自定义注解的两个属性上表示这两个互为别名,两个属性其实是同一个含义相互替代

  • @ComponentScan 注解:默认扫描当前类所在包及其子级包下的所有文件

  • @EnableAutoConfiguration 注解:启用 SpringBoot 的自动配置机制

    1
    2
    3
    4
    5
    6
    7
    @AutoConfigurationPackage	
    @Import(AutoConfigurationImportSelector.class)
    public @interface EnableAutoConfiguration {
    String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";
    Class<?>[] exclude() default {};
    String[] excludeName() default {};
    }
    • @AutoConfigurationPackage:将添加该注解的类所在的 package 作为自动配置 package 进行管理,把启动类所在的包设置一次,为了给各种自动配置的第三方库扫描用,比如带 @Mapper 注解的类,Spring 自身是不能识别的,但自动配置的 Mybatis 需要扫描用到,而 ComponentScan 只是用来扫描注解类,并没有提供接口给三方使用

      1
      2
      3
      4
      5
      @Import(AutoConfigurationPackages.Registrar.class)	// 利用 Registrar 给容器中导入组件
      public @interface AutoConfigurationPackage {
      String[] basePackages() default {}; //自动配置包,指定了配置类的包
      Class<?>[] basePackageClasses() default {};
      }

      register(registry, new PackageImports(metadata).getPackageNames().toArray(new String[0])):注册 BD

      • new PackageImports(metadata).getPackageNames():获取添加当前注解的类的所在包
      • registry.registerBeanDefinition(BEAN, new BasePackagesBeanDefinition(packageNames)):存放到容器中
        • new BasePackagesBeanDefinition(packageNames):把当前主类所在的包名封装到该对象中
    • @Import(AutoConfigurationImportSelector.class):自动装配的核心类

      容器刷新时执行:invokeBeanFactoryPostProcessors() → invokeBeanDefinitionRegistryPostProcessors() → postProcessBeanDefinitionRegistry() → processConfigBeanDefinitions() → parse() → process() → processGroupImports() → getImports() → process() → AutoConfigurationImportSelector#getAutoConfigurationEntry()

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      protected AutoConfigurationEntry getAutoConfigurationEntry(AnnotationMetadata annotationMetadata) {
      if (!isEnabled(annotationMetadata)) {
      return EMPTY_ENTRY;
      }
      // 获取注解属性,@SpringBootApplication 注解的 exclude 属性和 excludeName 属性
      AnnotationAttributes attributes = getAttributes(annotationMetadata);
      // 获取所有需要自动装配的候选项
      List<String> configurations = getCandidateConfigurations(annotationMetadata, attributes);
      // 去除重复的选项
      configurations = removeDuplicates(configurations);
      // 获取注解配置的排除的自动装配类
      Set<String> exclusions = getExclusions(annotationMetadata, attributes);
      checkExcludedClasses(configurations, exclusions);
      // 移除所有的配置的不需要自动装配的类
      configurations.removeAll(exclusions);
      // 过滤,条件装配
      configurations = getConfigurationClassFilter().filter(configurations);
      // 获取 AutoConfigurationImportListener 类的监听器调用 onAutoConfigurationImportEvent 方法
      fireAutoConfigurationImportEvents(configurations, exclusions);
      // 包装成 AutoConfigurationEntry 返回
      return new AutoConfigurationEntry(configurations, exclusions);
      }

      AutoConfigurationImportSelector#getCandidateConfigurations:获取自动配置的候选项

      • List<String> configurations = SpringFactoriesLoader.loadFactoryNames():加载自动配置类

        参数一:getSpringFactoriesLoaderFactoryClass():获取 @EnableAutoConfiguration 注解类

        参数二:getBeanClassLoader():获取类加载器

        • factoryTypeName = factoryType.getName():@EnableAutoConfiguration 注解的全类名
        • return loadSpringFactories(classLoaderToUse).getOrDefault():加载资源
          • urls = classLoader.getResources(FACTORIES_RESOURCE_LOCATION):获取资源类
          • FACTORIES_RESOURCE_LOCATION = "META-INF/spring.factories"加载的资源的位置
      • return configurations:返回所有自动装配类的候选项

    • 从 spring-boot-autoconfigure-2.5.3.jar/META-INF/spring.factories 文件中寻找 EnableAutoConfiguration 字段,获取自动装配类,进行条件装配,按需装配


装配流程

Spring Boot 通过 @EnableAutoConfiguration 开启自动装配,通过 SpringFactoriesLoader 加载 META-INF/spring.factories 中的自动配置类实现自动装配,自动配置类其实就是通过 @Conditional 注解按需加载的配置类,想要其生效必须引入 spring-boot-starter-xxx 包实现起步依赖

  • SpringBoot 先加载所有的自动配置类 xxxxxAutoConfiguration
  • 每个自动配置类进行条件装配,默认都会绑定配置文件指定的值(xxxProperties 和配置文件进行了绑定)
  • SpringBoot 默认会在底层配好所有的组件,如果用户自己配置了以用户的优先
  • 定制化配置:
    • 用户可以使用 @Bean 新建自己的组件来替换底层的组件
    • 用户可以去看这个组件是获取的配置文件前缀值,在配置文件中修改

以 DispatcherServletAutoConfiguration 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)
// 类中的 Bean 默认不是单例
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = Type.SERVLET)
// 条件装配,环境中有 DispatcherServlet 类才进行自动装配
@ConditionalOnClass(DispatcherServlet.class)
@AutoConfigureAfter(ServletWebServerFactoryAutoConfiguration.class)
public class DispatcherServletAutoConfiguration {
// 注册的 DispatcherServlet 的 BeanName
public static final String DEFAULT_DISPATCHER_SERVLET_BEAN_NAME = "dispatcherServlet";

@Configuration(proxyBeanMethods = false)
@Conditional(DefaultDispatcherServletCondition.class)
@ConditionalOnClass(ServletRegistration.class)
// 绑定配置文件的属性,从配置文件中获取配置项
@EnableConfigurationProperties(WebMvcProperties.class)
protected static class DispatcherServletConfiguration {

// 给容器注册一个 DispatcherServlet,起名字为 dispatcherServlet
@Bean(name = DEFAULT_DISPATCHER_SERVLET_BEAN_NAME)
public DispatcherServlet dispatcherServlet(WebMvcProperties webMvcProperties) {
// 新建一个 DispatcherServlet 设置相关属性
DispatcherServlet dispatcherServlet = new DispatcherServlet();
// spring.mvc 中的配置项获取注入,没有就填充默认值
dispatcherServlet.setDispatchOptionsRequest(webMvcProperties.isDispatchOptionsRequest());
// ......
// 返回该对象注册到容器内
return dispatcherServlet;
}

@Bean
// 容器中有这个类型组件才进行装配
@ConditionalOnBean(MultipartResolver.class)
// 容器中没有这个名字 multipartResolver 的组件
@ConditionalOnMissingBean(name = DispatcherServlet.MULTIPART_RESOLVER_BEAN_NAME)
// 方法名就是 BeanName
public MultipartResolver multipartResolver(MultipartResolver resolver) {
// 给 @Bean 标注的方法传入了对象参数,这个参数就会从容器中找,因为用户自定义了该类型,以用户配置的优先
// 但是名字不符合规范,所以获取到该 Bean 并返回到容器一个规范的名称:multipartResolver
return resolver;
}
}
}
1
2
3
// 将配置文件中的 spring.mvc 前缀的属性与该类绑定
@ConfigurationProperties(prefix = "spring.mvc")
public class WebMvcProperties { }

事件监听

SpringBoot 在项目启动时,会对几个监听器进行回调,可以实现监听器接口,在项目启动时完成一些操作

ApplicationContextInitializer、SpringApplicationRunListener、CommandLineRunner、ApplicationRunner

  • MyApplicationRunner

    自定义监听器的启动时机:MyApplicationRunner 和 MyCommandLineRunner 都是当项目启动后执行,使用 @Component 放入容器即可使用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    //当项目启动后执行run方法
    @Component
    public class MyApplicationRunner implements ApplicationRunner {
    @Override
    public void run(ApplicationArguments args) throws Exception {
    System.out.println("ApplicationRunner...run");
    System.out.println(Arrays.asList(args.getSourceArgs()));//properties配置信息
    }
    }
  • MyCommandLineRunner

    1
    2
    3
    4
    5
    6
    7
    8
    @Component
    public class MyCommandLineRunner implements CommandLineRunner {
    @Override
    public void run(String... args) throws Exception {
    System.out.println("CommandLineRunner...run");
    System.out.println(Arrays.asList(args));
    }
    }
  • MyApplicationContextInitializer 的启用要在 resource 文件夹下添加 META-INF/spring.factories

    1
    2
    org.springframework.context.ApplicationContextInitializer=\
    com.example.springbootlistener.listener.MyApplicationContextInitializer
    1
    2
    3
    4
    5
    6
    7
    @Component
    public class MyApplicationContextInitializer implements ApplicationContextInitializer {
    @Override
    public void initialize(ConfigurableApplicationContext applicationContext) {
    System.out.println("ApplicationContextInitializer....initialize");
    }
    }
  • MySpringApplicationRunListener 的使用要添加构造器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    public class MySpringApplicationRunListener implements SpringApplicationRunListener {
    //构造器
    public MySpringApplicationRunListener(SpringApplication sa, String[] args) {
    }

    @Override
    public void starting() {
    System.out.println("starting...项目启动中");//输出SPRING之前
    }

    @Override
    public void environmentPrepared(ConfigurableEnvironment environment) {
    System.out.println("environmentPrepared...环境对象开始准备");
    }

    @Override
    public void contextPrepared(ConfigurableApplicationContext context) {
    System.out.println("contextPrepared...上下文对象开始准备");
    }

    @Override
    public void contextLoaded(ConfigurableApplicationContext context) {
    System.out.println("contextLoaded...上下文对象开始加载");
    }

    @Override
    public void started(ConfigurableApplicationContext context) {
    System.out.println("started...上下文对象加载完成");
    }

    @Override
    public void running(ConfigurableApplicationContext context) {
    System.out.println("running...项目启动完成,开始运行");
    }

    @Override
    public void failed(ConfigurableApplicationContext context, Throwable exception) {
    System.out.println("failed...项目启动失败");
    }
    }

配置文件

配置方式

文件类型

SpringBoot 是基于约定的,很多配置都有默认值,如果想使用自己的配置替换默认配置,可以使用 application.properties 或者application.yml(application.yaml)进行配置

  • 默认配置文件名称:application
  • 在同一级目录下优先级为:properties > yml > yaml

例如配置内置 Tomcat 的端口

  • properties:

    1
    server.port=8080
  • yml:

    1
    server: port: 8080
  • yaml:

    1
    server: port: 8080

加载顺序

所有位置的配置文件都会被加载,互补配置,高优先级配置内容会覆盖低优先级配置内容

扫描配置文件的位置按优先级从高到底

  • file:./config/当前项目下的 /config 目录下

  • file:./:当前项目的根目录,Project工程目录

  • classpath:/config/:classpath 的 /config 目录

  • classpath:/:classpath 的根目录,就是 resoureces 目录

项目外部配置文件加载顺序:外部配置文件的使用是为了对内部文件的配合

  • 命令行:在 package 打包后的 target 目录下,使用该命令

    1
    java -jar myproject.jar --server.port=9000
  • 指定配置文件位置

    1
    java -jar myproject.jar --spring.config.location=e://application.properties
  • 按优先级从高到底选择配置文件的加载命令

    1
    java -jar myproject.jar

yaml语法

基本语法:

  • 大小写敏感

  • 数据值前边必须有空格,作为分隔符

  • 使用缩进表示层级关系

  • 缩进时不允许使用Tab键,只允许使用空格(各个系统 Tab对应空格数目可能不同,导致层次混乱)

  • 缩进的空格数目不重要,只要相同层级的元素左侧对齐即可

  • ‘’#” 表示注释,从这个字符一直到行尾,都会被解析器忽略

    1
    2
    3
    server: 
    port: 8080
    address: 127.0.0.1

数据格式:

  • 纯量:单个的、不可再分的值

    1
    2
    msg1: 'hello \n world'  # 单引忽略转义字符
    msg2: "hello \n world" # 双引识别转义字符
  • 对象:键值对集合,Map、Hash

    1
    2
    3
    4
    5
    person:  
    name: zhangsan
    age: 20
    # 行内写法
    person: {name: zhangsan}

    注意:不建议使用 JSON,应该使用 yaml 语法

  • 数组:一组按次序排列的值,List、Array

    1
    2
    3
    4
    5
    address:
    - beijing
    - shanghai
    # 行内写法
    address: [beijing,shanghai]
    1
    2
    3
    4
    5
    allPerson	#List<Person>
    - {name:lisi, age:18}
    - {name:wangwu, age:20}
    # 行内写法
    allPerson: [{name:lisi, age:18}, {name:wangwu, age:20}]
  • 参数引用:

    1
    2
    3
    name: lisi 
    person:
    name: ${name} # 引用上边定义的name值

获取配置

三种获取配置文件的方式:

  • 注解 @Value

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    @RestController
    public class HelloController {
    @Value("${name}")
    private String name;

    @Value("${person.name}")
    private String name2;

    @Value("${address[0]}")
    private String address1;

    @Value("${msg1}")
    private String msg1;

    @Value("${msg2}")
    private String msg2;

    @RequestMapping("/hello")
    public String hello(){
    System.out.println("所有的数据");
    return " hello Spring Boot !";
    }
    }
  • Evironment 对象

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Autowired
    private Environment env;

    @RequestMapping("/hello")
    public String hello() {
    System.out.println(env.getProperty("person.name"));
    System.out.println(env.getProperty("address[0]"));
    return " hello Spring Boot !";
    }
  • 注解 @ConfigurationProperties 配合 @Component 使用

    注意:参数 prefix 一定要指定

    1
    2
    3
    4
    5
    6
    7
    @Component	//不扫描该组件到容器内,无法完成自动装配
    @ConfigurationProperties(prefix = "person")
    public class Person {
    private String name;
    private int age;
    private String[] address;
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Autowired
    private Person person;

    @RequestMapping("/hello")
    public String hello() {
    System.out.println(person);
    //Person{name='zhangsan', age=20, address=[beijing, shanghai]}
    return " hello Spring Boot !";
    }

配置提示

自定义的类和配置文件绑定一般没有提示,添加如下依赖可以使用提示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>

<!-- 下面插件作用是工程打包时,不将spring-boot-configuration-processor打进包内,让其只在编码的时候有用 -->
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>

Profile

@Profile:指定组件在哪个环境的情况下才能被注册到容器中,不指定,任何环境下都能注册这个组件

  • 加了环境标识的 bean,只有这个环境被激活的时候才能注册到容器中,默认是 default 环境
  • 写在配置类上,只有是指定的环境的时候,整个配置类里面的所有配置才能开始生效
  • 没有标注环境标识的 bean 在,任何环境下都是加载的

Profile 的配置:

  • profile 是用来完成不同环境下,配置动态切换功能

  • profile 配置方式:多 profile 文件方式,提供多个配置文件,每个代表一种环境

    • application-dev.properties/yml 开发环境
    • application-test.properties/yml 测试环境
    • sapplication-pro.properties/yml 生产环境
  • yml 多文档方式:在 yml 中使用 — 分隔不同配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    ---
    server:
    port: 8081
    spring:
    profiles:dev
    ---
    server:
    port: 8082
    spring:
    profiles:test
    ---
    server:
    port: 8083
    spring:
    profiles:pro
    ---
  • profile 激活方式

    • 配置文件:在配置文件中配置:spring.profiles.active=dev

      1
      spring.profiles.active=dev
    • 虚拟机参数:在VM options 指定:-Dspring.profiles.active=dev

    • 命令行参数:java –jar xxx.jar --spring.profiles.active=dev

      在 Program arguments 里输入,也可以先 package


Web开发

功能支持

SpringBoot 自动配置了很多约定,大多场景都无需自定义配置

  • 内容协商视图解析器 ContentNegotiatingViewResolver 和 BeanName 视图解析器 BeanNameViewResolver
  • 支持静态资源(包括 webjars)和静态 index.html 页支持
  • 自动注册相关类:Converter、GenericConverter、Formatter
  • 内容协商处理器:HttpMessageConverters
  • 国际化:MessageCodesResolver

开发规范:

  • 使用 @Configuration + WebMvcConfigurer 自定义规则,不使用 @EnableWebMvc 注解
  • 声明 WebMvcRegistrations 的实现类改变默认底层组件
  • 使用 @EnableWebMvc + @Configuration + DelegatingWebMvcConfiguration 全面接管 SpringMVC

静态资源

访问规则

默认的静态资源路径是 classpath 下的,优先级由高到低为:/META-INF/resources、/resources、 /static、/public 的包内,/ 表示当前项目的根路径

静态映射 /** ,表示请求 / + 静态资源名 就直接去默认的资源路径寻找请求的资源

处理原理:静请求去寻找 Controller 处理,不能处理的请求就会交给静态资源处理器,静态资源也找不到就响应 404 页面


欢迎页面

静态资源路径下 index.html 默认作为欢迎页面,访问 http://localhost:8080 出现该页面,使用 welcome page 功能不能修改前缀

网页标签上的小图标可以自定义规则,把资源重命名为 favicon.ico 放在静态资源目录下即可


源码分析

SpringMVC 功能的自动配置类 WebMvcAutoConfiguration:

1
2
3
4
public class WebMvcAutoConfiguration {
//当前项目的根路径
private static final String SERVLET_LOCATION = "/";
}
  • 内部类 WebMvcAutoConfigurationAdapter:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    @Import(EnableWebMvcConfiguration.class)
    // 绑定 spring.mvc、spring.web、spring.resources 相关的配置属性
    @EnableConfigurationProperties({ WebMvcProperties.class,ResourceProperties.class, WebProperties.class })
    @Order(0)
    public static class WebMvcAutoConfigurationAdapter implements WebMvcConfigurer, ServletContextAware {
    //有参构造器所有参数的值都会从容器中确定
    public WebMvcAutoConfigurationAdapter(/*参数*/) {
    this.resourceProperties = resourceProperties.hasBeenCustomized() ? resourceProperties
    : webProperties.getResources();
    this.mvcProperties = mvcProperties;
    this.beanFactory = beanFactory;
    this.messageConvertersProvider = messageConvertersProvider;
    this.resourceHandlerRegistrationCustomizer = resourceHandlerRegistrationCustomizerProvider.getIfAvailable();
    this.dispatcherServletPath = dispatcherServletPath;
    this.servletRegistrations = servletRegistrations;
    this.mvcProperties.checkConfiguration();
    }
    }
    • ResourceProperties resourceProperties:获取和 spring.resources 绑定的所有的值的对象
    • WebMvcProperties mvcProperties:获取和 spring.mvc 绑定的所有的值的对象
    • ListableBeanFactory beanFactory:Spring 的 beanFactory
    • HttpMessageConverters:找到所有的 HttpMessageConverters
    • ResourceHandlerRegistrationCustomizer:找到 资源处理器的自定义器。
    • DispatcherServletPath:项目路径
    • ServletRegistrationBean:给应用注册 Servlet、Filter
  • WebMvcAutoConfiguration.WebMvcAutoConfigurationAdapter.addResourceHandler():两种静态资源映射规则

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
    //配置文件设置 spring.resources.add-mappings: false,禁用所有静态资源
    if (!this.resourceProperties.isAddMappings()) {
    logger.debug("Default resource handling disabled");//被禁用
    return;
    }
    //注册webjars静态资源的映射规则 映射 路径
    addResourceHandler(registry, "/webjars/**", "classpath:/META-INF/resources/webjars/");
    //注册静态资源路径的映射规则 默认映射 staticPathPattern = "/**"
    addResourceHandler(registry, this.mvcProperties.getStaticPathPattern(), (registration) -> {
    //staticLocations = CLASSPATH_RESOURCE_LOCATIONS
    registration.addResourceLocations(this.resourceProperties.getStaticLocations());
    if (this.servletContext != null) {
    ServletContextResource resource = new ServletContextResource(this.servletContext, SERVLET_LOCATION);
    registration.addResourceLocations(resource);
    }
    });
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    @ConfigurationProperties("spring.web")
    public class WebProperties {
    public static class Resources {
    //默认资源路径,优先级从高到低
    static final String[] CLASSPATH_RESOURCE_LOCATIONS = { "classpath:/META-INF/resources/",
    "classpath:/resources/",
    "classpath:/static/", "classpath:/public/" }
    private String[] staticLocations = CLASSPATH_RESOURCE_LOCATIONS;
    //可以进行规则重写
    public void setStaticLocations(String[] staticLocations) {
    this.staticLocations = appendSlashIfNecessary(staticLocations);
    this.customized = true;
    }
    }
    }
  • WebMvcAutoConfiguration.EnableWebMvcConfiguration.welcomePageHandlerMapping():欢迎页

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    //spring.web 属性
    @EnableConfigurationProperties(WebProperties.class)
    public static class EnableWebMvcConfiguration {
    @Bean
    public WelcomePageHandlerMapping welcomePageHandlerMapping(/*参数*/) {
    WelcomePageHandlerMapping welcomePageHandlerMapping = new WelcomePageHandlerMapping(
    new TemplateAvailabilityProviders(applicationContext),
    applicationContext, getWelcomePage(),
    //staticPathPattern = "/**"
    this.mvcProperties.getStaticPathPattern());
    return welcomePageHandlerMapping;
    }
    }
    WelcomePageHandlerMapping(/*参数*/) {
    //所以限制 staticPathPattern 必须为 /** 才能启用该功能
    if (welcomePage != null && "/**".equals(staticPathPattern)) {
    logger.info("Adding welcome page: " + welcomePage);
    //重定向
    setRootViewName("forward:index.html");
    }
    else if (welcomeTemplateExists(templateAvailabilityProviders, applicationContext)) {
    logger.info("Adding welcome page template: index");
    setRootViewName("index");
    }
    }

    WelcomePageHandlerMapping,访问 / 能访问到 index.html


Rest映射

开启 Rest 功能

1
2
3
4
5
spring:
mvc:
hiddenmethod:
filter:
enabled: true #开启页面表单的Rest功能

源码分析,注入了 HiddenHttpMethodFilte 解析 Rest 风格的访问:

1
2
3
4
5
6
7
8
public class WebMvcAutoConfiguration {
@Bean
@ConditionalOnMissingBean(HiddenHttpMethodFilter.class)
@ConditionalOnProperty(prefix = "spring.mvc.hiddenmethod.filter", name = "enabled")
public OrderedHiddenHttpMethodFilter hiddenHttpMethodFilter() {
return new OrderedHiddenHttpMethodFilter();
}
}

详细源码解析:SpringMVC → 基本操作 → Restful → 识别原理

Web 部分源码详解:SpringMVC → 运行原理


内嵌容器

SpringBoot 嵌入式 Servlet 容器,默认支持的 WebServe:Tomcat、Jetty、Undertow

配置方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion> <!--必须要把内嵌的 Tomcat 容器-->
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jetty</artifactId>
</dependency>

Web 应用启动,SpringBoot 导入 Web 场景包 tomcat,创建一个 Web 版的 IOC 容器:

  • SpringApplication.run(BootApplication.class, args):应用启动

  • ConfigurableApplicationContext.run()

    • context = createApplicationContext()创建容器

      • applicationContextFactory = ApplicationContextFactory.DEFAULT

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        ApplicationContextFactory DEFAULT = (webApplicationType) -> {
        try {
        switch (webApplicationType) {
        case SERVLET:
        // Servlet 容器,继承自 ServletWebServerApplicationContext
        return new AnnotationConfigServletWebServerApplicationContext();
        case REACTIVE:
        // 响应式编程
        return new AnnotationConfigReactiveWebServerApplicationContext();
        default:
        // 普通 Spring 容器
        return new AnnotationConfigApplicationContext();
        }
        } catch (Exception ex) {
        throw new IllegalStateException();
        }
        }
      • applicationContextFactory.create(this.webApplicationType):根据应用类型创建容器

    • refreshContext(context):容器启动刷新

内嵌容器工作流程:

  • Spring 容器启动逻辑中,在实例化非懒加载的单例 Bean 之前有一个方法 **onRefresh()**,留给子类去扩展,Web 容器就是重写这个方法创建 WebServer

    1
    2
    3
    4
    5
    6
    7
    8
    9
    protected void onRefresh() {
    //省略....
    createWebServer();
    }
    private void createWebServer() {
    ServletWebServerFactory factory = getWebServerFactory();
    this.webServer = factory.getWebServer(getSelfInitializer());
    createWebServer.end();
    }

    获取 WebServer 工厂 ServletWebServerFactory,并且获取的数量不等于 1 会报错,Spring 底层有三种:

    TomcatServletWebServerFactoryJettyServletWebServerFactoryUndertowServletWebServerFactory

  • 自动配置类 ServletWebServerFactoryAutoConfiguration 导入了 ServletWebServerFactoryConfiguration(配置类),根据条件装配判断系统中到底导入了哪个 Web 服务器的包,创建出服务器并启动

  • 默认是 web-starter 导入 tomcat 包,容器中就有 TomcatServletWebServerFactory,创建出 Tomcat 服务器并启动,

    1
    2
    3
    4
    public TomcatWebServer(Tomcat tomcat, boolean autoStart, Shutdown shutdown) {
    // 初始化
    initialize();
    }

    初始化方法 initialize 中有启动方法:this.tomcat.start()


自定义

定制规则

1
2
3
4
5
6
7
8
9
10
11
@Configuration
public class MyWebMvcConfigurer implements WebMvcConfigurer {
@Bean
public WebMvcConfigurer webMvcConfigurer() {
return new WebMvcConfigurer() {
//进行一些方法重写,来实现自定义的规则
//比如添加一些解析器和拦截器,就是对原始容器功能的增加
}
}
//也可以不加 @Bean,直接从这里重写方法进行功能增加
}

定制容器

@EnableWebMvc:全面接管 SpringMVC,所有规则全部自己重新配置

  • @EnableWebMvc + WebMvcConfigurer + @Bean 全面接管SpringMVC

  • @Import(DelegatingWebMvcConfiguration.class),该类继承 WebMvcConfigurationSupport,自动配置了一些非常底层的组件,只能保证 SpringMVC 最基本的使用

原理:自动配置类 WebMvcAutoConfiguration 里面的配置要能生效,WebMvcConfigurationSupport 类不能被加载,所以 @EnableWebMvc 导致配置类失效,从而接管了 SpringMVC

1
2
@ConditionalOnMissingBean(WebMvcConfigurationSupport.class)
public class WebMvcAutoConfiguration {}

注意:一般不适用此注解


数据访问

JDBC

基本使用

导入 starter:

1
2
3
4
5
6
7
8
9
10
11
<!--导入 JDBC 场景-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jdbc</artifactId>
</dependency>
<!--导入 MySQL 驱动-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<!--版本对应你的 MySQL 版本<version>5.1.49</version>-->
</dependency>

单独导入 MySQL 驱动是因为不确定用户使用的什么数据库

配置文件:

1
2
3
4
5
6
spring:
datasource:
url: jdbc:mysql://192.168.0.107:3306/db1?useSSL=false # 不加 useSSL 会警告
username: root
password: 123456
driver-class-name: com.mysql.jdbc.Driver

测试文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Slf4j
@SpringBootTest
class Boot05WebAdminApplicationTests {

@Autowired
JdbcTemplate jdbcTemplate;

@Test
void contextLoads() {
Long res = jdbcTemplate.queryForObject("select count(*) from account_tbl", Long.class);
log.info("记录总数:{}", res);
}
}

自动配置

DataSourceAutoConfiguration:数据源的自动配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ DataSource.class, EmbeddedDatabaseType.class })
@EnableConfigurationProperties(DataSourceProperties.class)
public class DataSourceAutoConfiguration {

@Conditional(PooledDataSourceCondition.class)
@ConditionalOnMissingBean({ DataSource.class, XADataSource.class })
@Import({ DataSourceConfiguration.Hikari.class, DataSourceConfiguration.Tomcat.class,
DataSourceConfiguration.Dbcp2.class, DataSourceConfiguration.OracleUcp.class})
protected static class PooledDataSourceConfiguration {}
}
// 配置项
@ConfigurationProperties(prefix = "spring.datasource")
public class DataSourceProperties implements BeanClassLoaderAware, InitializingBean {}
  • 底层默认配置好的连接池是:HikariDataSource
  • 数据库连接池的配置,是容器中没有 DataSource 才自动配置的
  • 修改数据源相关的配置:spring.datasource

相关配置:

  • DataSourceTransactionManagerAutoConfiguration: 事务管理器的自动配置
  • JdbcTemplateAutoConfiguration: JdbcTemplate 的自动配置
    • 可以修改这个配置项 @ConfigurationProperties(prefix = “spring.jdbc”) 来修改JdbcTemplate
    • @AutoConfigureAfter(DataSourceAutoConfiguration.class):在 DataSource 装配后装配
  • JndiDataSourceAutoConfiguration: jndi 的自动配置
  • XADataSourceAutoConfiguration: 分布式事务相关

Druid

导入坐标:

1
2
3
4
5
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.17</version>
</dependency>
1
2
3
4
5
6
7
8
9
@Configuration
@ConditionalOnClass(DruidDataSource.class)
@AutoConfigureBefore(DataSourceAutoConfiguration.class)
@EnableConfigurationProperties({DruidStatProperties.class, DataSourceProperties.class})
@Import({DruidSpringAopConfiguration.class,
DruidStatViewServletConfiguration.class,
DruidWebStatFilterConfiguration.class,
DruidFilterConfiguration.class})
public class DruidDataSourceAutoConfigure {}

自动配置:

  • 扩展配置项 spring.datasource.druid

  • DruidSpringAopConfiguration: 监控 SpringBean,配置项为 spring.datasource.druid.aop-patterns

  • DruidStatViewServletConfiguration:监控页的配置项为 spring.datasource.druid.stat-view-servlet,默认开启

  • DruidWebStatFilterConfiguration:Web 监控配置项为 spring.datasource.druid.web-stat-filter,默认开启

  • DruidFilterConfiguration:所有 Druid 自己 filter 的配置

配置示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
spring:
datasource:
url: jdbc:mysql://localhost:3306/db_account
username: root
password: 123456
driver-class-name: com.mysql.jdbc.Driver

druid:
aop-patterns: com.atguigu.admin.* #监控SpringBean
filters: stat,wall # 底层开启功能,stat(sql监控),wall(防火墙)

stat-view-servlet: # 配置监控页功能
enabled: true
login-username: admin #项目启动访问:http://localhost:8080/druid ,账号和密码是 admin
login-password: admin
resetEnable: false

web-stat-filter: # 监控web
enabled: true
urlPattern: /*
exclusions: '*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*'


filter:
stat: # 对上面filters里面的stat的详细配置
slow-sql-millis: 1000
logSlowSql: true
enabled: true
wall:
enabled: true
config:
drop-table-allow: false

配置示例:https://github.com/alibaba/druid/tree/master/druid-spring-boot-starter

配置项列表:https://github.com/alibaba/druid/wiki/DruidDataSource%E9%85%8D%E7%BD%AE%E5%B1%9E%E6%80%A7%E5%88%97%E8%A1%A8


MyBatis

基本使用

导入坐标:

1
2
3
4
5
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.4</version>
</dependency>
  • 编写 MyBatis 相关配置:application.yml

    1
    2
    3
    4
    5
    6
    7
    8
    # 配置mybatis规则
    mybatis:
    # config-location: classpath:mybatis/mybatis-config.xml 建议不写
    mapper-locations: classpath:mybatis/mapper/*.xml
    configuration:
    map-underscore-to-camel-case: true

    #可以不写全局配置文件,所有全局配置文件的配置都放在 configuration 配置项中即可
  • 定义表和实体类

    1
    2
    3
    4
    5
    public class User {
    private int id;
    private String username;
    private String password;
    }
  • 编写 dao 和 mapper 文件/纯注解开发

    dao:**@Mapper 注解必须加,使用自动装配的 package,否则在启动类指定 @MapperScan() 扫描路径(不建议)**

    1
    2
    3
    4
    5
    @Mapper  //必须加Mapper
    @Repository
    public interface UserXmlMapper {
    public List<User> findAll();
    }

    mapper.xml

    1
    2
    3
    4
    5
    6
    7
    <?xml version="1.0" encoding="UTF-8" ?>
    <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
    <mapper namespace="com.seazean.springbootmybatis.mapper.UserXmlMapper">
    <select id="findAll" resultType="user">
    select * from t_user
    </select>
    </mapper>
  • 纯注解开发

    1
    2
    3
    4
    5
    6
    @Mapper
    @Repository
    public interface UserMapper {
    @Select("select * from t_user")
    public List<User> findAll();
    }

自动配置

MybatisAutoConfiguration:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@EnableConfigurationProperties(MybatisProperties.class)	//MyBatis配置项绑定类。
@AutoConfigureAfter({ DataSourceAutoConfiguration.class, MybatisLanguageDriverAutoConfiguration.class })
public class MybatisAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
return factory.getObject();
}

@org.springframework.context.annotation.Configuration
@Import(AutoConfiguredMapperScannerRegistrar.class)
@ConditionalOnMissingBean({ MapperFactoryBean.class, MapperScannerConfigurer.class })
public static class MapperScannerRegistrarNotFoundConfiguration implements InitializingBean {}
}

@ConfigurationProperties(prefix = "mybatis")
public class MybatisProperties {}
  • 配置文件:mybatis
  • 自动配置了 SqlSessionFactory
  • 导入 AutoConfiguredMapperScannerRegistra 实现 @Mapper 的扫描

MyBatis-Plus

1
2
3
4
5
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.1</version>
</dependency>

自动配置类:MybatisPlusAutoConfiguration

只需要 Mapper 继承 BaseMapper 就可以拥有 CRUD 功能


Redis

基本使用

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
  • 配置redis相关属性

    1
    2
    3
    4
    spring:
    redis:
    host: 127.0.0.1 # redis的主机ip
    port: 6379
  • 注入 RedisTemplate 模板

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    @RunWith(SpringRunner.class)
    @SpringBootTest
    public class SpringbootRedisApplicationTests {
    @Autowired
    private RedisTemplate redisTemplate;

    @Test
    public void testSet() {
    //存入数据
    redisTemplate.boundValueOps("name").set("zhangsan");
    }
    @Test
    public void testGet() {
    //获取数据
    Object name = redisTemplate.boundValueOps("name").get();
    System.out.println(name);
    }
    }

自动配置

RedisAutoConfiguration 自动配置类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(RedisOperations.class)
@EnableConfigurationProperties(RedisProperties.class)
@Import({ LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class })
public class RedisAutoConfiguration {
@Bean
@ConditionalOnMissingBean(name = "redisTemplate")
@ConditionalOnSingleCandidate(RedisConnectionFactory.class)
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
return template;
}

@Bean
@ConditionalOnMissingBean
@ConditionalOnSingleCandidate(RedisConnectionFactory.class)
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
StringRedisTemplate template = new StringRedisTemplate();
template.setConnectionFactory(redisConnectionFactory);
return template;
}

}
  • 配置项:spring.redis

  • 自动导入了连接工厂配置类:LettuceConnectionConfiguration、JedisConnectionConfiguration

  • 自动注入了模板类:RedisTemplate<Object, Object> 、StringRedisTemplate,k v 都是 String 类型

  • 使用 @Autowired 注入模板类就可以操作 redis


单元测试

Junit5

Spring Boot 2.2.0 版本开始引入 JUnit 5 作为单元测试默认库,由三个不同的子模块组成:

  • JUnit Platform:在 JVM 上启动测试框架的基础,不仅支持 Junit 自制的测试引擎,其他测试引擎也可以接入

  • JUnit Jupiter:提供了 JUnit5 的新的编程模型,是 JUnit5 新特性的核心,内部包含了一个测试引擎,用于在 Junit Platform 上运行

  • JUnit Vintage:JUnit Vintage 提供了兼容 JUnit4.x、Junit3.x 的测试引擎

    注意:SpringBoot 2.4 以上版本移除了默认对 Vintage 的依赖,如果需要兼容 Junit4 需要自行引入

1
2
3
4
5
@SpringBootTest
class Boot05WebAdminApplicationTests {
@Test
void contextLoads() { }
}

常用注解

JUnit5 的注解如下:

  • @Test:表示方法是测试方法,但是与 JUnit4 的 @Test 不同,它的职责非常单一不能声明任何属性,拓展的测试将会由 Jupiter 提供额外测试,包是 org.junit.jupiter.api.Test

  • @ParameterizedTest:表示方法是参数化测试

  • @RepeatedTest:表示方法可重复执行

  • @DisplayName:为测试类或者测试方法设置展示名称

  • @BeforeEach:表示在每个单元测试之前执行

  • @AfterEach:表示在每个单元测试之后执行

  • @BeforeAll:表示在所有单元测试之前执行

  • @AfterAll:表示在所有单元测试之后执行

  • @Tag:表示单元测试类别,类似于 JUnit4 中的 @Categories

  • @Disabled:表示测试类或测试方法不执行,类似于 JUnit4 中的 @Ignore

  • @Timeout:表示测试方法运行如果超过了指定时间将会返回错误

  • @ExtendWith:为测试类或测试方法提供扩展类引用


断言机制

简单断言

断言(assertions)是测试方法中的核心,用来对测试需要满足的条件进行验证,断言方法都是 org.junit.jupiter.api.Assertions 的静态方法

用来对单个值进行简单的验证:

方法 说明
assertEquals 判断两个对象或两个原始类型是否相等
assertNotEquals 判断两个对象或两个原始类型是否不相等
assertSame 判断两个对象引用是否指向同一个对象
assertNotSame 判断两个对象引用是否指向不同的对象
assertTrue 判断给定的布尔值是否为 true
assertFalse 判断给定的布尔值是否为 false
assertNull 判断给定的对象引用是否为 null
assertNotNull 判断给定的对象引用是否不为 null
1
2
3
4
5
6
7
@Test
@DisplayName("simple assertion")
public void simple() {
assertEquals(3, 1 + 2, "simple math");
assertNull(null);
assertNotNull(new Object());
}

数组断言

通过 assertArrayEquals 方法来判断两个对象或原始类型的数组是否相等

1
2
3
4
5
@Test
@DisplayName("array assertion")
public void array() {
assertArrayEquals(new int[]{1, 2}, new int[] {1, 2});
}

组合断言

assertAll 方法接受多个 org.junit.jupiter.api.Executable 函数式接口的实例作为验证的断言,可以通过 lambda 表达式提供这些断言

1
2
3
4
5
6
7
8
@Test
@DisplayName("assert all")
public void all() {
assertAll("Math",
() -> assertEquals(2, 1 + 1),
() -> assertTrue(1 > 0)
);
}

异常断言

Assertions.assertThrows(),配合函数式编程就可以进行使用

1
2
3
4
5
6
7
8
@Test
@DisplayName("异常测试")
public void exceptionTest() {
ArithmeticException exception = Assertions.assertThrows(
//扔出断言异常
ArithmeticException.class, () -> System.out.println(1 / 0)
);
}

超时断言

Assertions.assertTimeout() 为测试方法设置了超时时间

1
2
3
4
5
6
@Test
@DisplayName("超时测试")
public void timeoutTest() {
//如果测试方法时间超过1s将会异常
Assertions.assertTimeout(Duration.ofMillis(1000), () -> Thread.sleep(500));
}

快速失败

通过 fail 方法直接使得测试失败

1
2
3
4
5
@Test
@DisplayName("fail")
public void shouldFail() {
fail("This should fail");
}

前置条件

JUnit 5 中的前置条件(assumptions)类似于断言,不同之处在于不满足的断言会使得测试方法失败,而不满足的前置条件只会使得测试方法的执行终止,前置条件可以看成是测试方法执行的前提,当该前提不满足时,就没有继续执行的必要

1
2
3
4
5
6
7
@DisplayName("测试前置条件")
@Test
void testassumptions(){
Assumptions.assumeTrue(false,"结果不是true");
System.out.println("111111");

}

嵌套测试

JUnit 5 可以通过 Java 中的内部类和 @Nested 注解实现嵌套测试,从而可以更好的把相关的测试方法组织在一起,在内部类中可以使用 @BeforeEach 和 @AfterEach 注解,而且嵌套的层次没有限制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@DisplayName("A stack")
class TestingAStackDemo {

Stack<Object> stack;

@Test
@DisplayName("is instantiated with new Stack()")
void isInstantiatedWithNew() {
assertNull(stack)
}

@Nested
@DisplayName("when new")
class WhenNew {

@BeforeEach
void createNewStack() {
stack = new Stack<>();
}

@Test
@DisplayName("is empty")
void isEmpty() {
assertTrue(stack.isEmpty());
}

@Test
@DisplayName("throws EmptyStackException when popped")
void throwsExceptionWhenPopped() {
assertThrows(EmptyStackException.class, stack::pop);
}
}
}

参数测试

参数化测试是 JUnit5 很重要的一个新特性,它使得用不同的参数多次运行测试成为了可能

利用**@ValueSource**等注解,指定入参,我们将可以使用不同的参数进行多次单元测试,而不需要每新增一个参数就新增一个单元测试,省去了很多冗余代码。

  • @ValueSource:为参数化测试指定入参来源,支持八大基础类以及 String 类型、Class 类型

  • @NullSource:表示为参数化测试提供一个 null 的入参

  • @EnumSource:表示为参数化测试提供一个枚举入参

  • @CsvFileSource:表示读取指定 CSV 文件内容作为参数化测试入参

  • @MethodSource:表示读取指定方法的返回值作为参数化测试入参(注意方法返回需要是一个流)


指标监控

Actuator

每一个微服务在云上部署以后,都需要对其进行监控、追踪、审计、控制等,SpringBoot 抽取了 Actuator 场景,使得每个微服务快速引用即可获得生产级别的应用监控、审计等功能

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

暴露所有监控信息为 HTTP:

1
2
3
4
5
6
management:
endpoints:
enabled-by-default: true #暴露所有端点信息
web:
exposure:
include: '*' #以web方式暴露

访问 http://localhost:8080/actuator/[beans/health/metrics/]

可视化界面:https://github.com/codecentric/spring-boot-admin


Endpoint

默认所有的 Endpoint 除过 shutdown 都是开启的

1
2
3
4
5
6
7
8
management:
endpoints:
enabled-by-default: false #禁用所有的
endpoint: #手动开启一部分
beans:
enabled: true
health:
enabled: true

端点:

ID 描述
auditevents 暴露当前应用程序的审核事件信息。需要一个 AuditEventRepository 组件
beans 显示应用程序中所有 Spring Bean 的完整列表
caches 暴露可用的缓存
conditions 显示自动配置的所有条件信息,包括匹配或不匹配的原因
configprops 显示所有 @ConfigurationProperties
env 暴露 Spring 的属性 ConfigurableEnvironment
flyway 显示已应用的所有 Flyway 数据库迁移。 需要一个或多个 Flyway 组件。
health 显示应用程序运行状况信息
httptrace 显示 HTTP 跟踪信息,默认情况下 100 个 HTTP 请求-响应需要一个 HttpTraceRepository 组件
info 显示应用程序信息
integrationgraph 显示 Spring integrationgraph,需要依赖 spring-integration-core
loggers 显示和修改应用程序中日志的配置
liquibase 显示已应用的所有 Liquibase 数据库迁移,需要一个或多个 Liquibase 组件
metrics 显示当前应用程序的指标信息。
mappings 显示所有 @RequestMapping 路径列表
scheduledtasks 显示应用程序中的计划任务
sessions 允许从 Spring Session 支持的会话存储中检索和删除用户会话,需要使用 Spring Session 的基于 Servlet 的 Web 应用程序
shutdown 使应用程序正常关闭,默认禁用
startup 显示由 ApplicationStartup 收集的启动步骤数据。需要使用 SpringApplication 进行配置 BufferingApplicationStartup
threaddump 执行线程转储

应用程序是 Web 应用程序(Spring MVC,Spring WebFlux 或 Jersey),则可以使用以下附加端点:

ID 描述
heapdump 返回 hprof 堆转储文件。
jolokia 通过 HTTP 暴露 JMX bean(需要引入 Jolokia,不适用于 WebFlux),需要引入依赖 jolokia-core
logfile 返回日志文件的内容(如果已设置 logging.file.namelogging.file.path 属性),支持使用 HTTP Range标头来检索部分日志文件的内容。
prometheus 以 Prometheus 服务器可以抓取的格式公开指标,需要依赖 micrometer-registry-prometheus

常用 Endpoint:

  • Health:监控状况

  • Metrics:运行时指标

  • Loggers:日志记录


项目部署

SpringBoot 项目开发完毕后,支持两种方式部署到服务器:

  • jar 包 (官方推荐,默认)
  • war 包

更改 pom 文件中的打包方式为 war

  • 修改启动类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @SpringBootApplication
    public class SpringbootDeployApplication extends SpringBootServletInitializer {
    public static void main(String[] args) {
    SpringApplication.run(SpringbootDeployApplication.class, args);
    }

    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder b) {
    return b.sources(SpringbootDeployApplication.class);
    }
    }
  • 指定打包的名称

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <packaging>war</packaging>
    <build>
    <finalName>springboot</finalName>
    <plugins>
    <plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    </plugin>
    </plugins>
    </build>

1
2
3
4
5
title: Frame
date: 2022-01-01 00:00:00
tags: Frame
categories: Frame
comment

Maven

基本介绍

Mvn介绍

Maven:本质是一个项目管理工具,将项目开发和管理过程抽象成一个项目对象模型(POM)

POM:Project Object Model 项目对象模型。Maven 是用 Java 语言编写的,管理的东西以面向对象的形式进行设计,最终把一个项目看成一个对象,这个对象叫做 POM

pom.xml:Maven 需要一个 pom.xml 文件,Maven 通过加载这个配置文件可以知道项目的相关信息,这个文件代表就一个项目。如果做 8 个项目,对应的是 8 个 pom.xml 文件

依赖管理:Maven 对项目所有依赖资源的一种管理,它和项目之间是一种双向关系,即做项目时可以管理所需要的其他资源,当其他项目需要依赖我们项目时,Maven 也会把我们的项目当作一种资源去进行管理。

管理资源的存储位置:本地仓库,私服,中央仓库

基本作用:

  • 项目构建:提供标准的,跨平台的自动化构建项目的方式

  • 依赖管理:方便快捷的管理项目依赖的资源(jar 包),避免资源间的版本冲突等问题

  • 统一开发结构:提供标准的,统一的项目开发结构

各目录存放资源类型说明:

  • src/main/java:项目 java 源码

  • src/main/resources:项目的相关配置文件(比如 mybatis 配置,xml 映射配置,自定义配置文件等)

  • src/main/webapp:web 资源(比如 html、css、js 等)

  • src/test/java:测试代码

  • src/test/resources:测试相关配置文件

  • src/pom.xml:项目 pom 文件

参考视频:https://www.bilibili.com/video/BV1Ah411S7ZE


基础概念

仓库:用于存储资源,主要是各种 jar 包。有本地仓库,私服,中央仓库,私服和中央仓库都是远程仓库

  • 中央仓库:Maven 团队自身维护的仓库,属于开源的

  • 私服:各公司/部门等小范围内存储资源的仓库,私服也可以从中央仓库获取资源,作用:

    • 保存具有版权的资源,包含购买或自主研发的 jar
    • 一定范围内共享资源,能做到仅对内不对外开放
  • 本地仓库:开发者自己电脑上存储资源的仓库,也可从远程仓库获取资源

坐标:Maven 中的坐标用于描述仓库中资源的位置

  • 作用:使用唯一标识,唯一性定义资源位置,通过该标识可以将资源的识别与下载工作交由机器完成

  • 依赖设置:

    • groupId:定义当前资源隶属组织名称(通常是域名反写,如:org.mybatis)
  • artifactId:定义当前资源的名称(通常是项目或模块名称,如:crm、sms)

    • version:定义当前资源的版本号
  • packaging:定义资源的打包方式,取值一般有如下三种

    • jar:该资源打成 jar 包,默认是 jar

    • war:该资源打成 war 包

    • pom:该资源是一个父资源(表明使用 Maven 分模块管理),打包时只生成一个 pom.xml 不生成 jar 或其他包结构


环境搭建

环境配置

Maven 的官网:http://maven.apache.org/

下载安装:Maven 是一个绿色软件,解压即安装

目录结构:

  • bin:可执行程序目录
  • boot:Maven 自身的启动加载器
  • conf:Maven 配置文件的存放目录
  • lib:Maven运行所需库的存放目录

配置 MAVEN_HOME:

Path 下配置:%MAVEN_HOME%\bin

环境变量配置好之后需要测试环境配置结果,在 DOS 命令窗口下输入以下命令查看输出:mvn -v


仓库配置

默认情况 Maven 本地仓库在系统用户目录下的 .m2/repository,修改 Maven 的配置文件 conf/settings.xml 来修改仓库位置

  • 修改本地仓库位置:找到 标签,修改默认值

    1
    2
    3
    4
    5
    6
    <!-- localRepository
    | The path to the local repository maven will use to store artifacts.
    | Default: ${user.home}/.m2/repository
    <localRepository>/path/to/local/repo</localRepository>
    -->
    <localRepository>E:\Workspace\Java\Project\.m2\repository</localRepository>

    注意:在仓库的同级目录即 .m2 也应该包含一个 settings.xml 配置文件,局部用户配置优先与全局配置

    • 全局 setting 定义了 Maven 的公共配置
    • 用户 setting 定义了当前用户的配置
  • 修改远程仓库:在配置文件中找到 <mirrors> 标签,在这组标签下添加国内镜像

    1
    2
    3
    4
    5
    6
    <mirror>
    <id>nexus-aliyun</id>
    <mirrorOf>central</mirrorOf> <!--必须是central-->
    <name>Nexus aliyun</name>
    <url>http://maven.aliyun.com/nexus/content/groups/public</url>
    </mirror>
  • 修改默认 JDK:在配置文件中找到 <profiles> 标签,添加配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <profile> 
    <id>jdk-10</id>
    <activation>
    <activeByDefault>true</activeByDefault>
    <jdk>10</jdk>
    </activation>
    <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>10</maven.compiler.source>
    <maven.compiler.target>10</maven.compiler.target>
    </properties>
    </profile>

项目搭建

手动搭建

  1. 在 E 盘下创建目录 mvnproject 进入该目录,作为我们的操作目录

  2. 创建我们的 Maven 项目,创建一个目录 project-java 作为我们的项目文件夹,并进入到该目录

  3. 创建 Java 代码(源代码)所在目录,即创建 src/main/java

  4. 创建配置文件所在目录,即创建 src/main/resources

  5. 创建测试源代码所在目录,即创建 src/test/java

  6. 创建测试存放配置文件存放目录,即 src/test/resources

  7. src/main/java 中创建一个包(注意在 Windos 文件夹下就是创建目录)demo,在该目录下创建 Demo.java 文件,作为演示所需 Java 程序,内容如下

    1
    2
    3
    4
    5
    6
    7
    package demo;
    public class Demo{
    public String say(String name){
    System.out.println("hello "+name);
    return "hello "+name;
    }
    }
  8. src/test/java 中创建一个测试包(目录)demo,在该包下创建测试程序 DemoTest.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    package demo;
    import org.junit.*;
    public class DemoTest{
    @Test
    public void testSay(){
    Demo d = new Demo();
    String ret = d.say("maven");
    Assert.assertEquals("hello maven",ret);
    }
    }
  9. project-java/src 下创建 pom.xml 文件,格式如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    <?xml version="1.0" encoding="UTF-8"?>
    <project
    xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
    http://maven.apache.org/maven-v4_0_0.xsd">

    <!--指定pom的模型版本-->
    <modelVersion>4.0.0</modelVersion>
    <!--打包方式,web工程打包为war,java工程打包为jar -->
    <packaging>jar</packaging>

    <!--组织id-->
    <groupId>demo</groupId>
    <!--项目id-->
    <artifactId>project-java</artifactId>
    <!--版本号:release,snapshot-->
    <version>1.0</version>

    <!--设置当前工程的所有依赖-->
    <dependencies>
    <!--具体的依赖-->
    <dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.12</version>
    </dependency>
    </dependencies>
    </project>
  10. 搭建完成 Maven 的项目结构,通过 Maven 来构建项目。Maven 的构建命令以 mvn 开头,后面添加功能参数,可以一次性执行多个命令,用空格分离

    • mvn compile:编译
    • mvn clean:清理
    • mvn test:测试
    • mvn package:打包
    • mvn install:安装到本地仓库

    注意:执行某一条命令,则会把前面所有的都执行一遍


IDEA搭建

不用原型

  1. 在 IDEA 中配置 Maven,选择 maven3.6.1 防止依赖问题

    IDEA配置Maven
  2. 创建 Maven,New Module → Maven → 不选中 Create from archetype

  3. 填写项目的坐标

    • GroupId:demo
    • ArtifactId:project-java
  4. 查看各目录颜色标记是否正确

  5. IDEA 右侧侧栏有 Maven Project,打开后有 Lifecycle 生命周期

  6. 自定义 Maven 命令:Run → Edit Configurations → 左上角 + → Maven


使用原型

普通工程:

  1. 创建 Maven 项目的时候选择使用原型骨架

  2. 创建完成后发现通过这种方式缺少一些目录,需要手动去补全目录,并且要对补全的目录进行标记

Web 工程:

  1. 选择 Web 对应的原型骨架(选择 Maven 开头的是简化的)

  2. 通过原型创建 Web 项目得到的目录结构是不全的,因此需要我们自行补全,同时要标记正确

  3. Web 工程创建之后需要启动运行,使用 tomcat 插件来运行项目,在 pom.xml 中添加插件的坐标:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
    http://maven.apache.org/maven-v4_0_0.xsd">

    <modelVersion>4.0.0</modelVersion>
    <packaging>war</packaging>

    <name>web01</name>
    <groupId>demo</groupId>
    <artifactId>web01</artifactId>
    <version>1.0-SNAPSHOT</version>

    <dependencies>
    </dependencies>

    <!--构建-->
    <build>
    <!--设置插件-->
    <plugins>
    <!--具体的插件配置-->
    <plugin>
    <!--https://mvnrepository.com/ 搜索-->
    <groupId>org.apache.tomcat.maven</groupId>
    <artifactId>tomcat7-maven-plugin</artifactId>
    <version>2.1</version>
    <configuration>
    <port>80</port> <!--80端口默认不显示-->
    <path>/</path>
    </configuration>
    </plugin>
    </plugins>
    </build>
    </project>
  4. 插件配置以后,在 IDEA 右侧 maven-project 操作面板看到该插件,并且可以利用该插件启动项目,web01 → Plugins → tomcat7 → tomcat7:run


依赖管理

依赖配置

依赖是指在当前项目中运行所需的 jar,依赖配置的格式如下:

1
2
3
4
5
6
7
8
9
10
11
12
<!--设置当前项目所依赖的所有jar-->
<dependencies>
<!--设置具体的依赖-->
<dependency>
<!--依赖所属群组id-->
<groupId>junit</groupId>
<!--依赖所属项目id-->
<artifactId>junit</artifactId>
<!--依赖版本号-->
<version>4.12</version>
</dependency>
</dependencies>

依赖传递

依赖具有传递性,分两种:

  • 直接依赖:在当前项目中通过依赖配置建立的依赖关系

  • 间接依赖:被依赖的资源如果依赖其他资源,则表明当前项目间接依赖其他资源

    注意:直接依赖和间接依赖其实也是一个相对关系

依赖传递的冲突问题:在依赖传递过程中产生了冲突,有三种优先法则

  • 路径优先:当依赖中出现相同资源时,层级越深,优先级越低,反之则越高

  • 声明优先:当资源在相同层级被依赖时,配置顺序靠前的覆盖靠后的

  • 特殊优先:当同级配置了相同资源的不同版本时,后配置的覆盖先配置的

可选依赖:对外隐藏当前所依赖的资源,不透明

1
2
3
4
5
6
7
<dependency>    
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.11</version>
<optional>true</optional>
<!--默认是false,true以后就变得不透明-->
</dependency>

排除依赖:主动断开依赖的资源,被排除的资源无需指定版本

1
2
3
4
5
6
7
8
9
10
11
<dependency>    
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<exclusions>
<exclusion>
<groupId>org.hamcrest</groupId> <!--排除这个资源-->
<artifactId>hamcrest-core</artifactId>
</exclusion>
</exclusions>
</dependency>

依赖范围

依赖的 jar 默认情况可以在任何地方可用,可以通过 scope 标签设定其作用范围,有三种:

  • 主程序范围有效(src/main 目录范围内)

  • 测试程序范围内有效(src/test 目录范围内)

  • 是否参与打包(package 指令范围内)

scope 标签的取值有四种:compile,test,provided,runtime

依赖范围的传递性:


生命周期

相关事件

Maven 的构建生命周期描述的是一次构建过程经历了多少个事件

最常用的一套流程:compile → test-compile → test → package → install

  • clean:清理工作

    • pre-clean:执行一些在 clean 之前的工作
    • clean:移除上一次构建产生的所有文件
    • post-clean:执行一些在 clean 之后立刻完成的工作
  • default:核心工作,例如编译,测试,打包,部署等,每个事件在执行之前都会将之前的所有事件依次执行一遍

  • site:产生报告,发布站点等

    • pre-site:执行一些在生成站点文档之前的工作
    • site:生成项目的站点文档
    • post-site:执行一些在生成站点文档之后完成的工作,并为部署做准备
    • site-deploy:将生成的站点文档部署到特定的服务器上

执行事件

Maven 的插件用来执行生命周期中的相关事件

  • 插件与生命周期内的阶段绑定,在执行到对应生命周期时执行对应的插件

  • Maven 默认在各个生命周期上都绑定了预先设定的插件来完成相应功能

  • 插件还可以完成一些自定义功能

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    <build>    
    <plugins>
    <plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-source-plugin</artifactId>
    <version>2.2.1</version>
    <!--执行-->
    <excutions>
    <!--具体执行位置-->
    <excution>
    <goals>
    <!--对源码进行打包,打包放在target目录-->
    <goal>jar</goal>
    <!--对测试代码进行打包-->
    <goal>test-jar</goal>
    </goals>
    <!--执行的生命周期-->
    <phase>generate-test-resources</phase>
    </excution>
    </excutions>
    </plugin>
    </plugins>
    </build>

模块开发

拆分

工程模块与模块划分:

  • ssm_pojo 拆分

    • 新建模块,拷贝原始项目中对应的相关内容到 ssm_pojo 模块中
    • 实体类(User)
    • 配置文件(无)
  • ssm_dao 拆分

    • 新建模块

    • 拷贝原始项目中对应的相关内容到 ssm_dao 模块中

      • 数据层接口(UserDao)

      • 配置文件:保留与数据层相关配置文件(3 个)

      • 注意:分页插件在配置中与 SqlSessionFactoryBean 绑定,需要保留

      • pom.xml:引入数据层相关坐标即可,删除 SpringMVC 相关坐标

        • Spring
        • MyBatis
        • Spring 整合 MyBatis
        • MySQL
        • druid
        • pagehelper
        • 直接依赖 ssm_pojo(对 ssm_pojo 模块执行 install 指令,将其安装到本地仓库)
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        <dependencies>    <!--导入资源文件pojo-->    
        <dependency>
        <groupId>demo</groupId>
        <artifactId>ssm_pojo</artifactId>
        <version>1.0-SNAPSHOT</version>
        </dependency>
        <!--spring环境-->
        <!--mybatis环境-->
        <!--mysql环境-->
        <!--spring整合jdbc-->
        <!--spring整合mybatis-->
        <!--druid连接池-->
        <!--分页插件坐标-->
        </dependencies>
  • ssm_service 拆分

    • 新建模块

    • 拷贝原始项目中对应的相关内容到 ssm_service 模块中

      • 业务层接口与实现类(UserService、UserServiceImpl)

      • 配置文件:保留与数据层相关配置文件(1 个)

      • pom.xml:引入数据层相关坐标即可,删除 SpringMVC 相关坐标

        • spring

        • junit

        • spring 整合 junit

        • 直接依赖 ssm_dao(对 ssm_dao 模块执行 install 指令,将其安装到本地仓库)

        • 间接依赖 ssm_pojo(由 ssm_dao 模块负责依赖关系的建立)

      • 修改 service 模块 Spring 核心配置文件名,添加模块名称,格式:applicationContext-service.xml

      • 修改 dao 模块 Spring 核心配置文件名,添加模块名称,格式:applicationContext-dao.xml

      • 修改单元测试引入的配置文件名称,由单个文件修改为多个文件

  • ssm_control 拆分

    • 新建模块(使用 webapp 模板)

    • 拷贝原始项目中对应的相关内容到 ssm_controller 模块中

      • 现层控制器类与相关设置类(UserController、异常相关……)

      • 配置文件:保留与表现层相关配置文件(1 个)、服务器相关配置文件(1 个)

      • pom.xml:引入数据层相关坐标即可,删除 SpringMVC 相关坐标

        • spring

        • springmvc

        • jackson

        • servlet

        • tomcat 服务器插件

        • 直接依赖 ssm_service(对 ssm_service 模块执行 install 指令,将其安装到本地仓库)

        • 间接依赖 ssm_dao、ssm_pojo

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        <dependencies>   
        <!--导入资源文件service-->
        <dependency>
        <groupId>demo</groupId>
        <artifactId>ssm_service</artifactId>
        <version>1.0-SNAPSHOT</version>
        </dependency>
        <!--springmvc环境-->
        <!--jackson相关坐标3个-->
        <!--servlet环境-->
        </dependencies>
        <build>
        <!--设置插件-->
        <plugins>
        <!--具体的插件配置-->
        <plugin>
        </plugin>
        </plugins>
        </build>
      • 修改 web.xml 配置文件中加载 Spring 环境的配置文件名称,使用*通配,加载所有 applicationContext- 开始的配置文件:

        1
        2
        3
        4
        5
        <!--加载配置文件-->
        <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>classpath*:applicationContext-*.xml</param-value>
        </context-param>
      • spring-mvc

        1
        <mvc:annotation-driven/><context:component-scan base-package="controller"/>

聚合

作用:聚合用于快速构建 Maven 工程,一次性构建多个项目/模块

制作方式:

  • 创建一个空模块,打包类型定义为 pom

    1
    <packaging>pom</packaging>
  • 定义当前模块进行构建操作时关联的其他模块名称

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    <?xml version="1.0" encoding="UTF-8"?><project xmlns="............">   
    <modelVersion>4.0.0</modelVersion>
    <groupId>demo</groupId>
    <artifactId>ssm</artifactId>
    <version>1.0-SNAPSHOT</version>
    <!--定义该工程用于构建管理-->
    <packaging>pom</packaging>
    <!--管理的工程列表-->
    <modules>
    <!--具体的工程名称-->
    <module>../ssm_pojo</module>
    <module>../ssm_dao</module>
    <module>../ssm_service</module>
    <module>../ssm_controller</module>
    </modules></project>

注意事项:参与聚合操作的模块最终执行顺序与模块间的依赖关系有关,与配置顺序无关


继承

作用:通过继承可以实现在子工程中沿用父工程中的配置

  • Maven 中的继承与 Java 中的继承相似,在子工程中配置继承关系

制作方式:

  • 在子工程中声明其父工程坐标与对应的位置

    1
    2
    3
    4
    5
    6
    7
    8
    <!--定义该工程的父工程-->
    <parent>
    <groupId>com.seazean</groupId>
    <artifactId>ssm</artifactId>
    <version>1.0-SNAPSHOT</version>
    <!--填写父工程的pom文件-->
    <relativePath>../ssm/pom.xml</relativePath>
    </parent>
  • 继承依赖的定义:在父工程中定义依赖管理

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <!--声明此处进行依赖管理,版本锁定-->
    <dependencyManagement>
    <!--具体的依赖-->
    <dependencies>
    <!--spring环境-->
    <dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context</artifactId>
    <version>5.1.9.RELEASE</version>
    </dependency>
    <!--等等所有-->
    </dependencies>
    </dependencyManagement>
  • 继承依赖的使用:在子工程中定义依赖关系,无需声明依赖版本,版本参照父工程中依赖的版本

    1
    2
    3
    4
    5
    6
    7
    <dependencies> 
    <!--spring环境-->
    <dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context</artifactId>
    </dependency>
    </dependencies>
  • 继承的资源:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    groupId:项目组ID,项目坐标的核心元素
    version:项目版本,项目坐标的核心因素
    description:项目的描述信息
    organization:项目的组织信息
    inceptionYear:项目的创始年份
    url:项目的URL地址
    developers:项目的开发者信息
    contributors:项目的贡献者信息
    distributionManagement:项目的部署配置
    issueManagement:项目的缺陷跟踪系统信息
    ciManagement:项目的持续集成系统信息
    scm:项目的版本控制系统信息
    malilingLists:项目的邮件列表信息
    properties:自定义的Maven属性
    dependencies:项目的依赖配置
    dependencyManagement:项目的依赖管理配置
    repositories:项目的仓库配置
    build:包括项目的源码目录配置、输出目录配置、插件配置、插件管理配置等
    reporting:包括项目的报告输出目录配置、报告插件配置等
  • 继承与聚合:

    作用:

    • 聚合用于快速构建项目

    • 继承用于快速配置

    相同点:

    • 聚合与继承的 pom.xml 文件打包方式均为 pom,可以将两种关系制作到同一个 pom 文件中

    • 聚合与继承均属于设计型模块,并无实际的模块内容

    不同点:

    • 聚合是在当前模块中配置关系,聚合可以感知到参与聚合的模块有哪些

    • 继承是在子模块中配置关系,父模块无法感知哪些子模块继承了自己


属性

  • 版本统一的重要性:

  • 属性类别:

    1. 自定义属性
    2. 内置属性
    3. setting 属性
    4. Java 系统属性
    5. 环境变量属性
  • 自定义属性:

    作用:等同于定义变量,方便统一维护

    定义格式:

    1
    2
    3
    4
    5
    <!--定义自定义属性,放在dependencyManagement上方-->
    <properties>
    <spring.version>5.1.9.RELEASE</spring.version>
    <junit.version>4.12</junit.version>
    </properties>
    • 聚合与继承的 pom.xml 文件打包方式均为 pom,可以将两种关系制作到同一个 pom 文件中

    • 聚合与继承均属于设计型模块,并无实际的模块内容

    调用格式:

    1
    2
    3
    4
    5
    <dependency>    
    <groupId>org.springframework</groupId>
    <artifactId>spring-context</artifactId>
    <version>${spring.version}</version>
    </dependency>
  • 内置属性:

    作用:使用 Maven 内置属性,快速配置

    调用格式:

    1
    ${project.basedir} or ${project.basedir}  <!--../ssm根目录-->${version} or ${project.version}
    • vresion 是 1.0-SNAPSHOT
    1
    2
    3
    <groupId>demo</groupId>
    <artifactId>ssm</artifactId>
    <version>1.0-SNAPSHOT</version>
  • setting 属性

    • 使用 Maven 配置文件 setting.xml 中的标签属性,用于动态配置

    调用格式:

    1
    ${settings.localRepository} 
  • Java 系统属性:

    作用:读取 Java 系统属性

    调用格式:

    1
    ${user.home}

    系统属性查询方式 cmd 命令:

    1
    mvn help:system 
  • 环境变量属性

    作用:使用 Maven 配置文件 setting.xml 中的标签属性,用于动态配置

    调用格式:

    1
    ${env.JAVA_HOME} 

    环境变量属性查询方式:

    1
    mvn help:system 

工程版本

SNAPSHOT(快照版本)

  • 项目开发过程中,为方便团队成员合作,解决模块间相互依赖和时时更新的问题,开发者对每个模块进行构建的时候,输出的临时性版本叫快照版本(测试阶段版本)

  • 快照版本会随着开发的进展不断更新

RELEASE(发布版本)

  • 项目开发到进入阶段里程碑后,向团队外部发布较为稳定的版本,这种版本所对应的构件文件是稳定的,即便进行功能的后续开发,也不会改变当前发布版本内容,这种版本称为发布版本

约定规范:

  • <主版本>.<次版本>.<增量版本>.<里程碑版本>

  • 主版本:表示项目重大架构的变更,如:Spring5 相较于 Spring4 的迭代

  • 次版本:表示有较大的功能增加和变化,或者全面系统地修复漏洞

  • 增量版本:表示有重大漏洞的修复

  • 里程碑版本:表明一个版本的里程碑(版本内部)。这样的版本同下一个正式版本相比,相对来说不是很稳定,有待更多的测试


资源配置

作用:在任意配置文件中加载 pom 文件中定义的属性

  • 父文件 pom.xml

    1
    2
    <properties>    
    <jdbc.url>jdbc:mysql://192.168.0.137:3306/ssm_db?useSSL=false</jdbc.url></properties>
  • 开启配置文件加载 pom 属性:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <!--配置资源文件对应的信息-->
    <resources>
    <resource>
    <!--设定配置文件对应的位置目录,支持使用属性动态设定路径-->
    <directory>${project.basedir}/src/main/resources</directory>
    <!--开启对配置文件的资源加载过滤-->
    <filtering>true</filtering>
    </resource>
    </resources>
  • properties 文件中调用格式:

    1
    2
    jdbc.driver=com.mysql.jdbc.Driverjdbc.url=${jdbc.url}
    jdbc.username=rootjdbc.password=123456

多环境配置

  • 环境配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    <!--创建多环境-->
    <profiles>
    <!--定义具体的环境:生产环境-->
    <profile>
    <!--定义环境对应的唯一名称-->
    <id>pro_env</id>
    <!--定义环境中专用的属性值-->
    <properties>
    <jdbc.url>jdbc:mysql://127.1.1.1:3306/ssm_db</jdbc.url>
    </properties>
    <!--设置默认启动-->
    <activation>
    <activeByDefault>true</activeByDefault>
    </activation>
    </profile>
    <!--定义具体的环境:开发环境-->
    <profile>
    <id>dev_env</id>
    ……
    </profile>
    </profiles>
  • 加载指定环境

    作用:加载指定环境配置

    调用格式:

    1
    mvn 指令 –P 环境定义id

    范例:

    1
    mvn install –P pro_env

跳过测试

命令:

1
mvn 指令 –D skipTests

注意事项:执行的指令生命周期必须包含测试环节

IEDA 界面:

配置跳过:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<plugin>  
<!--<groupId>org.apache.maven</groupId>-->
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.1</version>
<configuration>
<skipTests>true</skipTests><!--设置跳过测试-->
<includes> <!--包含指定的测试用例-->
<include>**/User*Test.java</include>
</includes>
<excludes><!--排除指定的测试用例-->
<exclude>**/User*TestCase.java</exclude>
</excludes>
</configuration>
</plugin>

私服

Nexus

Nexus 是 Sonatype 公司的一款 Maven 私服产品

下载地址:https://help.sonatype.com/repomanager3/download

启动服务器(命令行启动):

1
nexus.exe /run nexus

访问服务器(默认端口:8081):

1
http://localhost:8081

修改基础配置信息

  • 安装路径下 etc 目录中 nexus-default.properties 文件保存有 nexus 基础配置信息,例如默认访问端口

修改服务器运行配置信息

  • 安装路径下 bin 目录中 nexus.vmoptions 文件保存有 nexus 服务器启动的配置信息,例如默认占用内存空间

资源操作

仓库分类:

  • 宿主仓库 hosted

    • 保存无法从中央仓库获取的资源
      • 自主研发
      • 第三方非开源项目
  • 代理仓库 proxy

    • 代理远程仓库,通过 nexus 访问其他公共仓库,例如中央仓库
  • 仓库组 group

    • 将若干个仓库组成一个群组,简化配置
    • 仓库组不能保存资源,属于设计型仓库

资源上传,上传资源时提供对应的信息

  • 保存的位置(宿主仓库)

  • 资源文件

  • 对应坐标


IDEA操作

上传下载


访问私服

本地访问

配置本地仓库访问私服的权限(setting.xml)

1
2
3
4
5
6
7
8
9
10
11
12
<servers>  
<server>
<id>heima-release</id>
<username>admin</username>
<password>admin</password>
</server>
<server>
<id>heima-snapshots</id>
<username>admin</username>
<password>admin</password>
</server>
</servers>

配置本地仓库资源来源(setting.xml)

1
2
3
4
5
6
7
<mirrors> 
<mirror>
<id>nexus-heima</id>
<mirrorOf>*</mirrorOf>
<url>http://localhost:8081/repository/maven-public/</url>
</mirror>
</mirrors>

工程访问

配置当前项目访问私服上传资源的保存位置(pom.xml)

1
2
3
4
5
6
7
8
9
10
<distributionManagement> 
<repository>
<id>heima-release</id>
<url>http://localhost:8081/repository/heima-release/</url>
</repository>
<snapshotRepository>
<id>heima-snapshots</id>
<url>http://localhost:8081/repository/heima-snapshots/</url>
</snapshotRepository>
</distributionManagement>

发布资源到私服命令

1
mvn deploy

日志

Log4j

程序中的日志可以用来记录程序在运行时候的详情,并可以进行永久存储。

输出语句 日志技术
取消日志 需要修改代码,灵活性比较差 不需要修改代码,灵活性比较好
输出位置 只能是控制台 可以将日志信息写入到文件或者数据库中
多线程 和业务代码处于一个线程中 多线程方式记录日志,不影响业务代码的性能

Log4j 是 Apache 的一个开源项目。使用 Log4j,通过一个配置文件来灵活地进行配置,而不需要修改应用的代码。我们可以控制日志信息输送的目的地是控制台、文件等位置,也可以控制每一条日志的输出格式。


配置文件

配置文件的三个核心:

  • 配置根 Logger

    • 格式:log4j.rootLogger=日志级别,appenderName1,appenderName2,…

    • 日志级别:常见的五个级别:DEBUG < INFO < WARN < ERROR < FATAL(可以自定义)
      Log4j 规则:只输出级别不低于设定级别的日志信息

    • appenderName1:指定日志信息要输出地址。可以同时指定多个输出目的地,用逗号隔开:

      例如:log4j.rootLogger=INFO,ca,fa

  • Appenders(输出源):日志要输出的地方,如控制台(Console)、文件(Files)等

    • Appenders 取值:

      • org.apache.log4j.ConsoleAppender(控制台)
      • org.apache.log4j.FileAppender(文件)
    • ConsoleAppender 常用参数

      • ImmediateFlush=true:表示所有消息都会被立即输出,设为 false 则不输出,默认值是 true
      • Target=System.err:默认值是 System.out
    • FileAppender常用的选项

      • ImmediateFlush=true:表示所有消息都会被立即输出。设为 false 则不输出,默认值是 true

      • Append=false:true 表示将消息添加到指定文件中,原来的消息不覆盖。默认值是 true

      • File=E:/logs/logging.log4j:指定消息输出到 logging.log4j 文件中

  • Layouts (布局):日志输出的格式,常用的布局管理器:

    • org.apache.log4j.PatternLayout(可以灵活地指定布局模式)
  • org.apache.log4j.SimpleLayout(包含日志信息的级别和信息字符串)

  • org.apache.log4j.TTCCLayout(包含日志产生的时间、线程、类别等信息)

  • PatternLayout 常用的选项


日志应用

  • log4j 的配置文件,名字为 log4j.properties, 放在 src 根目录下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    log4j.rootLogger=I

    ### direct log messages to my ###
    log4j.appender.my=org.apache.log4j.ConsoleAppender
    log4j.appender.my.ImmediateFlush = true
    log4j.appender.my.Target=System.out
    log4j.appender.my.layout=org.apache.log4j.PatternLayout
    log4j.appender.my.layout.ConversionPattern=%d %t %5p %c{1}:%L - %m%n

    # fileAppender演示
    log4j.appender.fileAppender=org.apache.log4j.FileAppender
    log4j.appender.fileAppender.ImmediateFlush = true
    log4j.appender.fileAppender.Append=true
    log4j.appender.fileAppender.File=E:/log4j-log.log
    log4j.appender.fileAppender.layout=org.apache.log4j.PatternLayout
    log4j.appender.fileAppender.layout.ConversionPattern=%d %5p %c{1}:%L - %m%n
  • 测试类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // 测试类
    public class Log4JTest01 {
    //使用log4j的api来获取日志的对象
    //弊端:如果以后我们更换日志的实现类,那么下面的代码就需要跟着改
    //不推荐使用
    //private static final Logger LOGGER = Logger.getLogger(Log4JTest01.class);
    //使用slf4j里面的api来获取日志的对象
    //好处:如果以后我们更换日志的实现类,那么下面的代码不需要跟着修改
    //推荐使用
    private static final Logger LOGGER = LoggerFactory.getLogger(Log4JTest01.class);
    public static void main(String[] args) {
    //1.导入jar包
    //2.编写配置文件
    //3.在代码中获取日志的对象
    //4.按照日志级别设置日志信息
    LOGGER.debug("debug级别的日志");
    LOGGER.info("info级别的日志");
    LOGGER.warn("warn级别的日志");
    LOGGER.error("error级别的日志");
    }
    }

Netty

基本介绍

Netty 是一个异步事件驱动的网络应用程序框架,用于快速开发可维护、高性能的网络服务器和客户端

Netty 官网:https://netty.io/

Netty 的对 JDK 自带的 NIO 的 API 进行封装,解决上述问题,主要特点有:

  • 设计优雅,适用于各种传输类型的统一 API, 阻塞和非阻塞 Socket 基于灵活且可扩展的事件模型
  • 使用方便,详细记录的 Javadoc、用户指南和示例,没有其他依赖项
  • 高性能,吞吐量更高,延迟更低,减少资源消耗,最小化不必要的内存复制
  • 安全,完整的 SSL/TLS 和 StartTLS 支持

Netty 的功能特性:

  • 传输服务:支持 BIO 和 NIO
  • 容器集成:支持 OSGI、JBossMC、Spring、Guice 容器
  • 协议支持:HTTP、Protobuf、二进制、文本、WebSocket 等一系列协议都支持,也支持通过实行编码解码逻辑来实现自定义协议
  • Core 核心:可扩展事件模型、通用通信 API、支持零拷贝的 ByteBuf 缓冲对象

线程模型

阻塞模型

传统阻塞型 I/O 模式,每个连接都需要独立的线程完成数据的输入,业务处理,数据返回

模型缺点:

  • 当并发数较大时,需要创建大量线程来处理连接,系统资源占用较大
  • 连接建立后,如果当前线程暂时没有数据可读,则线程就阻塞在 read 操作上,造成线程资源浪费

参考文章:https://www.jianshu.com/p/2965fca6bb8f


Reactor

设计思想

Reactor 模式,通过一个或多个输入同时传递给服务处理器的事件驱动处理模式。 服务端程序处理传入的多路请求,并将它们同步分派给对应的处理线程,Reactor 模式也叫 Dispatcher 模式,即 I/O 多路复用统一监听事件,收到事件后分发(Dispatch 给某线程)

I/O 复用结合线程池,就是 Reactor 模式基本设计思想:

Reactor 模式关键组成:

  • Reactor:在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序来对 I/O 事件做出反应
  • Handler:处理程序执行 I/O 要完成的实际事件,Reactor 通过调度适当的处理程序来响应 I/O 事件,处理程序执行非阻塞操作

Reactor 模式具有如下的优点:

  • 响应快,不必为单个同步时间所阻塞,虽然 Reactor 本身依然是同步的
  • 编程相对简单,可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销
  • 可扩展性,可以方便的通过增加 Reactor 实例个数来充分利用 CPU 资源
  • 可复用性,Reactor 模型本身与具体事件处理逻辑无关,具有很高的复用性

根据 Reactor 的数量和处理资源池线程的数量不同,有三种典型的实现:

  • 单 Reactor 单线程
  • 单 Reactor 多线程
  • 主从 Reactor 多线程

单R单线程

Reactor 对象通过 select 监控客户端请求事件,收到事件后通过 dispatch 进行分发:

  • 如果是建立连接请求事件,则由 Acceptor 通过 accept 处理连接请求,然后创建一个 Handler 对象处理连接完成后的后续业务处理

  • 如果不是建立连接事件,则 Reactor 会分发给连接对应的 Handler 来响应,Handler 会完成 read、业务处理、send 的完整流程

    说明:Handler 和 Acceptor 属于同一个线程

模型优点:模型简单,没有多线程、进程通信、竞争的问题,全部都在一个线程中完成

模型缺点:

  • 性能问题:只有一个线程,无法发挥多核 CPU 的性能,Handler 在处理某个连接上的业务时,整个进程无法处理其他连接事件,很容易导致性能瓶颈
  • 可靠性问题:线程意外跑飞,或者进入死循环,会导致整个系统通信模块不可用,不能接收和处理外部消息,造成节点故障

使用场景:客户端的数量有限,业务处理非常快速,比如 Redis,业务处理的时间复杂度 O(1)


单R多线程

执行流程通同单 Reactor 单线程,不同的是:

  • Handler 只负责响应事件,不做具体业务处理,通过 read 读取数据后,会分发给后面的 Worker 线程池进行业务处理

  • Worker 线程池会分配独立的线程完成真正的业务处理,将响应结果发给 Handler 进行处理,最后由 Handler 收到响应结果后通过 send 将响应结果返回给 Client

模型优点:可以充分利用多核 CPU 的处理能力

模型缺点:

  • 多线程数据共享和访问比较复杂
  • Reactor 承担所有事件的监听和响应,在单线程中运行,高并发场景下容易成为性能瓶颈

主从模型

采用多个 Reactor ,执行流程:

  • Reactor 主线程 MainReactor 通过 select 监控建立连接事件,收到事件后通过 Acceptor 接收,处理建立连接事件,处理完成后 MainReactor 会将连接分配给 Reactor 子线程的 SubReactor(有多个)处理

  • SubReactor 将连接加入连接队列进行监听其他事件,并创建一个 Handler 用于处理该连接的事件,当有新的事件发生时,SubReactor 会调用连接对应的 Handler 进行响应

  • Handler 通过 read 读取数据后,会分发给 Worker 线程池进行业务处理

  • Worker 线程池会分配独立的线程完成真正的业务处理,将响应结果发给 Handler 进行处理,最后由 Handler 收到响应结果后通过 send 将响应结果返回给 Client

模型优点

  • 父线程与子线程的数据交互简单职责明确,父线程只需要接收新连接,子线程完成后续的业务处理
  • 父线程与子线程的数据交互简单,Reactor 主线程只需要把新连接传给子线程,子线程无需返回数据

使用场景:Nginx 主从 Reactor 多进程模型,Memcached 主从多线程,Netty 主从多线程模型的支持


Proactor

Reactor 模式中,Reactor 等待某个事件的操作状态发生变化(文件描述符可读写,socket 可读写),然后把事件传递给事先注册的 Handler 来做实际的读写操作,其中的读写操作都需要应用程序同步操作,所以 Reactor 是非阻塞同步网络模型(NIO)

把 I/O 操作改为异步,交给操作系统来完成就能进一步提升性能,这就是异步网络模型 Proactor(AIO):

工作流程:

  • ProactorInitiator 创建 Proactor 和 Handler 对象,并将 Proactor 和 Handler 通过 Asynchronous Operation Processor(AsyOptProcessor)注册到内核
  • AsyOptProcessor 处理注册请求,并处理 I/O 操作,完成I/O后通知 Proactor
  • Proactor 根据不同的事件类型回调不同的 Handler 进行业务处理,最后由 Handler 完成业务处理

对比:Reactor 在事件发生时就通知事先注册的处理器(读写在应用程序线程中处理完成);Proactor 是在事件发生时基于异步 I/O 完成读写操作(内核完成),I/O 完成后才回调应用程序的处理器进行业务处理

模式优点:异步 I/O 更加充分发挥 DMA(Direct Memory Access 直接内存存取)的优势

模式缺点:

  • 编程复杂性,由于异步操作流程的事件的初始化和事件完成在时间和空间上都是相互分离的,因此开发异步应用程序更加复杂,应用程序还可能因为反向的流控而变得更加难以调试
  • 内存使用,缓冲区在读或写操作的时间段内必须保持住,可能造成持续的不确定性,并且每个并发操作都要求有独立的缓存,Reactor 模式在 socket 准备好读或写之前是不要求开辟缓存的
  • 操作系统支持,Windows 下通过 IOCP 实现了真正的异步 I/O,而在 Linux 系统下,Linux2.6 才引入异步 I/O,目前还不完善,所以在 Linux 下实现高并发网络编程都是以 Reactor 模型为主

Netty

Netty 主要基于主从 Reactors 多线程模型做了一定的改进,Netty 的工作架构图:

工作流程:

  1. Netty 抽象出两组线程池 BossGroup 专门负责接收客户端的连接,WorkerGroup 专门负责网络的读写

  2. BossGroup 和 WorkerGroup 类型都是 NioEventLoopGroup,该 Group 相当于一个事件循环组,含有多个事件循环,每一个事件循环是 NioEventLoop,所以可以有多个线程

  3. NioEventLoop 表示一个循环处理任务的线程,每个 NioEventLoop 都有一个 Selector,用于监听绑定在其上的 Socket 的通讯

  4. 每个 Boss NioEventLoop 循环执行的步骤:

    • 轮询 accept 事件
    • 处理 accept 事件,与 client 建立连接,生成 NioScocketChannel,并将其注册到某个 Worker 中的某个 NioEventLoop 上的 Selector,连接就与 NioEventLoop 绑定
    • 处理任务队列的任务,即 runAllTasks
  5. 每个 Worker NioEventLoop 循环执行的步骤:

    • 轮询 read、write 事件
    • 处理 I/O 事件,即 read,write 事件,在对应 NioSocketChannel 处理
    • 处理任务队列的任务,即 runAllTasks
  6. 每个 Worker NioEventLoop 处理业务时,会使用 Pipeline(管道),Pipeline 中包含了 Channel,即通过 Pipeline 可以获取到对应通道,管道中维护了很多的处理器 Handler


基本实现

开发简单的服务器端和客户端,基本介绍:

  • Channel 理解为数据的通道,把 msg 理解为流动的数据,最开始输入是 ByteBuf,但经过 Pipeline 的加工,会变成其它类型对象,最后输出又变成 ByteBuf
  • Handler 理解为数据的处理工序,Pipeline 负责发布事件传播给每个 Handler,Handler 对自己感兴趣的事件进行处理(重写了相应事件处理方法),分 Inbound 和 Outbound 两类
  • EventLoop 理解为处理数据的执行者,既可以执行 IO 操作,也可以进行任务处理。每个执行者有任务队列,队列里可以堆放多个 Channel 的待处理任务,任务分为普通任务、定时任务。按照 Pipeline 顺序,依次按照 Handler 的规划(代码)处理数据

代码实现:

  • pom.xml

    1
    2
    3
    4
    5
    <dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-all</artifactId>
    <version>4.1.20.Final</version>
    </dependency>
  • Server.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    public class HelloServer {
    public static void main(String[] args) {
    EventLoopGroup boss = new NioEventLoopGroup();
    EventLoopGroup worker = new NioEventLoopGroup(2);
    // 1. 启动器,负责组装 netty 组件,启动服务器
    new ServerBootstrap()
    // 2. 线程组,boss 只负责【处理 accept 事件】, worker 只【负责 channel 上的读写】
    .group(boss, worker)
    //.option() // 给 ServerSocketChannel 配置参数
    //.childOption() // 给 SocketChannel 配置参数
    // 3. 选择服务器的 ServerSocketChannel 实现
    .channel(NioServerSocketChannel.class)
    // 4. boss 负责处理连接,worker(child) 负责处理读写,决定了能执行哪些操作(handler)
    .childHandler(new ChannelInitializer<NioSocketChannel>() {
    // 5. channel 代表和客户端进行数据读写的通道 Initializer 初始化,负责添加别的 handler
    // 7. 连接建立后,执行初始化方法
    @Override
    protected void initChannel(NioSocketChannel ch) throws Exception {
    // 添加具体的 handler
    ch.pipeline().addLast(new StringDecoder());// 将 ByteBuf 转成字符串
    ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { // 自定义 handler
    // 读事件
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
    // 打印转换好的字符串
    System.out.println(msg);
    }
    });
    }
    })
    // 6. 绑定监听端口
    .bind(8080);
    }
    }
  • Client.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    public class HelloClient {
    public static void main(String[] args) throws InterruptedException {
    // 1. 创建启动器类
    new Bootstrap()
    // 2. 添加 EventLoop
    .group(new NioEventLoopGroup())
    //.option(),给 SocketChannel 配置参数
    // 3. 选择客户端 channel 实现
    .channel(NioSocketChannel.class)
    // 4. 添加处理器
    .handler(new ChannelInitializer<NioSocketChannel>() {
    // 4.1 连接建立后被调用
    @Override
    protected void initChannel(NioSocketChannel ch) throws Exception {
    // 将 Hello World 转为 ByteBuf
    ch.pipeline().addLast(new StringEncoder());
    }
    })
    // 5. 连接到服务器,然后调用 4.1
    .connect(new InetSocketAddress("127.0.0.1",8080))
    // 6. 阻塞方法,直到连接建立
    .sync()
    // 7. 代表连接对象
    .channel()
    // 8. 向服务器发送数据
    .writeAndFlush("Hello World");
    }
    }

参考视频:https://www.bilibili.com/video/BV1py4y1E7oA


组件介绍

EventLoop

基本介绍

事件循环对象 EventLoop,本质是一个单线程执行器同时维护了一个 Selector,有 run 方法处理 Channel 上源源不断的 IO 事件

事件循环组 EventLoopGroup 是一组 EventLoop,Channel 会调用 Boss EventLoopGroup 的 register 方法来绑定其中一个 Worker 的 EventLoop,后续这个 Channel 上的 IO 事件都由此 EventLoop 来处理,保证了事件处理时的线程安全

EventLoopGroup 类 API:

  • EventLoop next():获取集合中下一个 EventLoop,EventLoopGroup 实现了 Iterable 接口提供遍历 EventLoop 的能力

  • Future<?> shutdownGracefully():优雅关闭的方法,会首先切换 EventLoopGroup 到关闭状态从而拒绝新的任务的加入,然后在任务队列的任务都处理完成后,停止线程的运行,从而确保整体应用是在正常有序的状态下退出的

  • <T> Future<T> submit(Callable<T> task):提交任务

  • ScheduledFuture<?> scheduleWithFixedDelay:提交定时任务


任务传递

把要调用的代码封装为一个任务对象,由下一个 handler 的线程来调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class EventLoopServer {
public static void main(String[] args) {
EventLoopGroup group = new DefaultEventLoopGroup();
new ServerBootstrap()
.group(new NioEventLoopGroup(), new NioEventLoopGroup(2))
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<NioSocketChannel>() {
@Override
protected void initChannel(NioSocketChannel ch) {
ch.pipeline().addLast("handler1", new ChannelInboundHandlerAdapter() {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf buf = (ByteBuf) msg;
log.debug(buf.toString(Charset.defaultCharset()));
ctx.fireChannelRead(msg); // 让消息【传递】给下一个 handler
}
}).addLast(group, "handler2", new ChannelInboundHandlerAdapter() {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf buf = (ByteBuf) msg;
log.debug(buf.toString(Charset.defaultCharset()));
}
});
}
})
.bind(8080);
}
}

源码分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public ChannelHandlerContext fireChannelRead(final Object msg) {
invokeChannelRead(findContextInbound(MASK_CHANNEL_READ), msg);
return this;
}
static void invokeChannelRead(final AbstractChannelHandlerContext next, Object msg) {
final Object m = next.pipeline.touch(ObjectUtil.checkNotNull(msg, "msg"), next);
EventExecutor executor = next.executor();
// 下一个 handler 的事件循环是否与当前的事件循环是同一个线程
if (executor.inEventLoop()) {
// 是,直接调用
next.invokeChannelRead(m);
} else {
// 不是,将要执行的代码作为任务提交给下一个 handler 处理
executor.execute(new Runnable() {
@Override
public void run() {
next.invokeChannelRead(m);
}
});
}
}

Channel

连接操作

Channel 类 API:

  • ChannelFuture close():关闭通道
  • ChannelPipeline pipeline():添加处理器
  • ChannelFuture write(Object msg):数据写入缓冲区
  • ChannelFuture writeAndFlush(Object msg):数据写入缓冲区并且刷出

ChannelFuture 类 API:

  • ChannelFuture sync():同步阻塞等待连接成功
  • ChannelFuture addListener(GenericFutureListener listener):异步等待

代码实现:

  • connect 方法是异步的,不等连接建立完成就返回,因此 channelFuture 对象中不能立刻获得到正确的 Channel 对象,需要等待
  • 连接未建立 channel 打印为 [id: 0x2e1884dd];建立成功打印为 [id: 0x2e1884dd, L:/127.0.0.1:57191 - R:/127.0.0.1:8080]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class ChannelClient {
public static void main(String[] args) throws InterruptedException {
ChannelFuture channelFuture = new Bootstrap()
.group(new NioEventLoopGroup())
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<NioSocketChannel>() {
@Override
protected void initChannel(NioSocketChannel ch) throws Exception {
ch.pipeline().addLast(new StringEncoder());
}
})
// 1. 连接服务器,【异步非阻塞】,main 调用 connect 方法,真正执行连接的是 nio 线程
.connect(new InetSocketAddress("127.0.0.1", 8080));
// 2.1 使用 sync 方法【同步】处理结果,阻塞当前线程,直到 nio 线程连接建立完毕
channelFuture.sync();
Channel channel = channelFuture.channel();
System.out.println(channel); // 【打印】
// 向服务器发送数据
channel.writeAndFlush("hello world");

**************************************************************************************二选一
// 2.2 使用 addListener 方法【异步】处理结果
channelFuture.addListener(new ChannelFutureListener() {
@Override
// nio 线程连接建立好以后,回调该方法
public void operationComplete(ChannelFuture future) throws Exception {
if (future.isSuccess()) {
Channel channel = future.channel();
channel.writeAndFlush("hello, world");
} else {
// 建立失败,需要关闭
future.channel().close();
}
}
});
}
}

关闭操作

关闭 EventLoopGroup 的运行,分为同步关闭和异步关闭

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class CloseFutureClient {
public static void main(String[] args) throws InterruptedException {
NioEventLoopGroup group = new NioEventLoopGroup();
ChannelFuture channelFuture = new Bootstrap()
// ....
.connect(new InetSocketAddress("127.0.0.1", 8080));
Channel channel = channelFuture.sync().channel();
// 发送数据
new Thread(() -> {
Scanner sc = new Scanner(System.in);
while (true) {
String line = sc.nextLine();
if (line.equals("q")) {
channel.close();
break;
}
channel.writeAndFlush(line);
}
}, "input").start();
// 获取 CloseFuture 对象
ChannelFuture closeFuture = channel.closeFuture();

// 1. 同步处理关闭
System.out.println("waiting close...");
closeFuture.sync();
System.out.println("处理关闭后的操作");
****************************************************
// 2. 异步处理关闭
closeFuture.addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture future) throws Exception {
System.out.println("处理关闭后的操作");
group.shutdownGracefully();
}
});
}
}

Future

基本介绍

Netty 中的 Future 与 JDK 中的 Future 同名,但是功能的实现不同

1
2
package io.netty.util.concurrent;
public interface Future<V> extends java.util.concurrent.Future<V>

Future 类 API:

  • V get():阻塞等待获取任务执行结果
  • V getNow():非阻塞获取任务结果,还未产生结果时返回 null
  • Throwable cause():非阻塞获取失败信息,如果没有失败,返回 null
  • Future<V> sync():等待任务结束,如果任务失败,抛出异常
  • boolean cancel(boolean mayInterruptIfRunning):取消任务
  • Future<V> addListener(GenericFutureListener listener):添加回调,异步接收结果
  • boolean isSuccess():判断任务是否成功
  • boolean isCancellable():判断任务是否取消
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class NettyFutureDemo {
public static void main(String[] args) throws Exception {
NioEventLoopGroup group = new NioEventLoopGroup();
EventLoop eventLoop = group.next();
Future<Integer> future = eventLoop.submit(new Callable<Integer>() {
@Override
public Integer call() throws Exception {
System.out.println("执行计算");
Thread.sleep(1000);
return 70;
}
});
future.getNow();
System.out.println(new Date() + "等待结果");
System.out.println(new Date() + "" + future.get());
}
}

扩展子类

Promise 类是 Future 的子类,可以脱离任务独立存在,作为两个线程间传递结果的容器

1
public interface Promise<V> extends Future<V>

Promise 类 API:

  • Promise<V> setSuccess(V result):设置成功结果
  • Promise<V> setFailure(Throwable cause):设置失败结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class NettyPromiseDemo {
public static void main(String[] args) throws Exception {
// 1. 准备 EventLoop 对象
EventLoop eventLoop = new NioEventLoopGroup().next();
// 2. 主动创建 promise
DefaultPromise<Integer> promise = new DefaultPromise<>(eventLoop);
// 3. 任意一个线程执行计算,计算完毕后向 promise 填充结果
new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
promise.setSuccess(200);
}).start();

// 4. 接受结果的线程
System.out.println(new Date() + "等待结果");
System.out.println(new Date() + "" + promise.get());
}
}

Pipeline

ChannelHandler 用来处理 Channel 上的各种事件,分为入站出站两种,所有 ChannelHandler 连接成双向链表就是 Pipeline

  • 入站处理器通常是 ChannelInboundHandlerAdapter 的子类,主要用来读取客户端数据,写回结果
  • 出站处理器通常是 ChannelOutboundHandlerAdapter 的子类,主要对写回结果进行加工(入站和出站是对于服务端来说的)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public static void main(String[] args) {
new ServerBootstrap()
.group(new NioEventLoopGroup())
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<NioSocketChannel>() {
@Override
protected void initChannel(NioSocketChannel ch) throws Exception {
// 1. 通过 channel 拿到 pipeline
ChannelPipeline pipeline = ch.pipeline();
// 2. 添加处理器 head -> h1 -> h2 -> h3 -> h4 -> tail
pipeline.addLast("h1", new ChannelInboundHandlerAdapter() {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
log.debug("1");
ByteBuf buf = (ByteBuf) msg;
String s = buf.toString(Charset.defaultCharset());
// 将数据传递给下一个【入站】handler,如果不调用该方法则链会断开
super.channelRead(ctx, s);
}
});
pipeline.addLast("h2", new ChannelInboundHandlerAdapter() {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
log.debug("2");
// 从【尾部开始向前触发】出站处理器
ch.writeAndFlush(ctx.alloc().buffer().writeBytes("server".getBytes()));
// 该方法会让管道从【当前 handler 向前】寻找出站处理器
// ctx.writeAndFlush();
}
});
pipeline.addLast("h3", new ChannelOutboundHandlerAdapter() {
@Override
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
log.debug("3");
super.write(ctx, msg, promise);
}
});
pipeline.addLast("h4", new ChannelOutboundHandlerAdapter() {
@Override
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
log.debug("4");
super.write(ctx, msg, promise);
}
});
}
})
.bind(8080);
}

服务器端依次打印:1 2 4 3 ,所以入站是按照 addLast 的顺序执行的,出站是按照 addLast 的逆序执行

一个 Channel 包含了一个 ChannelPipeline,而 ChannelPipeline 中又维护了一个由 ChannelHandlerContext 组成的双向链表,并且每个 ChannelHandlerContext 中关联着一个 ChannelHandler

入站事件和出站事件在一个双向链表中,两种类型的 handler 互不干扰:

  • 入站事件会从链表 head 往后传递到最后一个入站的 handler
  • 出站事件会从链表 tail 往前传递到最前一个出站的 handler


ByteBuf

基本介绍

ByteBuf 是对字节数据的封装,优点:

  • 池化,可以重用池中 ByteBuf 实例,更节约内存,减少内存溢出的可能
  • 读写指针分离,不需要像 ByteBuffer 一样切换读写模式
  • 可以自动扩容
  • 支持链式调用,使用更流畅
  • 零拷贝思想,例如 slice、duplicate、CompositeByteBuf

创建方法

创建方式

  • ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(10):创建了一个默认的 ByteBuf,初始容量是 10
1
2
3
4
5
6
public ByteBuf buffer() {
if (directByDefault) {
return directBuffer();
}
return heapBuffer();
}
  • ByteBuf buffer = ByteBufAllocator.DEFAULT.heapBuffer(10):创建池化基于堆的 ByteBuf

  • ByteBuf buffer = ByteBufAllocator.DEFAULT.directBuffer(10):创建池化基于直接内存的 ByteBuf

  • 推荐的创建方式:在添加处理器的方法中

    1
    2
    3
    4
    5
    6
    pipeline.addLast(new ChannelInboundHandlerAdapter() {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    ByteBuf buffer = ctx.alloc().buffer();
    }
    });

直接内存对比堆内存:

  • 直接内存创建和销毁的代价昂贵,但读写性能高(少一次内存复制),适合配合池化功能一起用
  • 直接内存对 GC 压力小,因为这部分内存不受 JVM 垃圾回收的管理,但也要注意及时主动释放

池化的意义在于可以重用 ByteBuf,高并发时池化功能更节约内存,减少内存溢出的可能,与非池化对比:

  • 非池化,每次都要创建新的 ByteBuf 实例,这个操作对直接内存代价昂贵,堆内存会增加 GC 压力
  • 池化,可以重用池中 ByteBuf 实例,并且采用了与 jemalloc 类似的内存分配算法提升分配效率

池化功能的开启,可以通过下面的系统环境变量来设置:

1
-Dio.netty.allocator.type={unpooled|pooled}	 # VM 参数
  • 4.1 以后,非 Android 平台默认启用池化实现,Android 平台启用非池化实现
  • 4.1 之前,池化功能还不成熟,默认是非池化实现

读写操作

ByteBuf 由四部分组成,最开始读写指针(双指针)都在 0 位置

写入方法:

方法名 说明 备注
writeBoolean(boolean value) 写入 boolean 值 用一字节 01|00 代表 true|false
writeByte(int value) 写入 byte 值
writeInt(int value) 写入 int 值 Big Endian,即 0x250,写入后 00 00 02 50
writeIntLE(int value) 写入 int 值 Little Endian,即 0x250,写入后 50 02 00 00
writeBytes(ByteBuf src) 写入 ByteBuf
writeBytes(byte[] src) 写入 byte[]
writeBytes(ByteBuffer src) 写入 NIO 的 ByteBuffer
int writeCharSequence(CharSequence s, Charset c) 写入字符串
  • 这些方法的未指明返回值的,其返回值都是 ByteBuf,意味着可以链式调用
  • 写入几位写指针后移几位,指向可以写入的位置
  • 网络传输,默认习惯是 Big Endian

扩容:写入数据时,容量不够了(初始容量是 10),这时会引发扩容

  • 如果写入后数据大小未超过 512,则选择下一个 16 的整数倍,例如写入后大小为 12 ,则扩容后 capacity 是 16
  • 如果写入后数据大小超过 512,则选择下一个 2^n,例如写入后大小为 513,则扩容后 capacity 是 2^10 = 1024(2^9=512 不够)
  • 扩容不能超过 max capacity 会报错

读取方法:

  • byte readByte():读取一个字节,读指针后移
  • byte getByte(int index):读取指定索引位置的字节,读指针不动
  • ByteBuf markReaderIndex():标记读数据的位置
  • ByteBuf resetReaderIndex():重置到标记位置,可以重复读取标记位置向后的数据

内存释放

Netty 中三种内存的回收:

  • UnpooledHeapByteBuf 使用的是 JVM 内存,只需等 GC 回收内存
  • UnpooledDirectByteBuf 使用的就是直接内存了,需要特殊的方法来回收内存
  • PooledByteBuf 和子类使用了池化机制,需要更复杂的规则来回收内存

Netty 采用了引用计数法来控制回收内存,每个 ByteBuf 都实现了 ReferenceCounted 接口,回收的规则:

  • 每个 ByteBuf 对象的初始计数为 1
  • 调用 release 方法计数减 1,如果计数为 0,ByteBuf 内存被回收
  • 调用 retain 方法计数加 1,表示调用者没用完之前,其它 handler 即使调用了 release 也不会造成回收
  • 当计数为 0 时,底层内存会被回收,这时即使 ByteBuf 对象还在,其各个方法均无法正常使用
1
2
3
4
5
6
ByteBuf buf = .ByteBufAllocator.DEFAULT.buffer(10)
try {
// 逻辑处理
} finally {
buf.release();
}

Pipeline 的存在,需要将 ByteBuf 传递给下一个 ChannelHandler,如果在 finally 中 release 了,就失去了传递性,处理规则:

  • 创建 ByteBuf 放入 Pipeline

  • 入站 ByteBuf 处理原则

    • 对原始 ByteBuf 不做处理,调用 ctx.fireChannelRead(msg) 向后传递,这时无须 release,反之不传递需要

    • 将原始 ByteBuf 转换为其它类型的 Java 对象,这时 ByteBuf 就没用了,此时必须 release

    • 如果出现异常,ByteBuf 没有成功传递到下一个 ChannelHandler,必须 release

    • 假设消息一直向后传,那么 TailContext 会负责释放未处理消息(原始的 ByteBuf)

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      // io.netty.channel.DefaultChannelPipeline#onUnhandledInboundMessage(java.lang.Object)
      protected void onUnhandledInboundMessage(Object msg) {
      try {
      logger.debug();
      } finally {
      ReferenceCountUtil.release(msg);
      }
      }
      // io.netty.util.ReferenceCountUtil#release(java.lang.Object)
      public static boolean release(Object msg) {
      if (msg instanceof ReferenceCounted) {
      return ((ReferenceCounted) msg).release();
      }
      return false;
      }
  • 出站 ByteBuf 处理原则

    • 出站消息最终都会转为 ByteBuf 输出,一直向前传,由 HeadContext flush 后 release
  • 不确定 ByteBuf 被引用了多少次,但又必须彻底释放,可以循环调用 release 直到返回 true


拷贝操作

零拷贝方法:

  • ByteBuf slice(int index, int length):对原始 ByteBuf 进行切片成多个 ByteBuf,切片后的 ByteBuf 并没有发生内存复制,共用原始 ByteBuf 的内存,切片后的 ByteBuf 维护独立的 read,write 指针

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public static void main(String[] args) {
    ByteBuf buf = ByteBufAllocator.DEFAULT.buffer(10);
    buf.writeBytes(new byte[]{'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'});
    // 在切片过程中并没有发生数据复制
    ByteBuf f1 = buf.slice(0, 5);
    f1.retain();
    ByteBuf f2 = buf.slice(5, 5);
    f2.retain();
    // 对 f1 进行相关的操作也会体现在 buf 上
    }
  • ByteBuf duplicate():截取原始 ByteBuf 所有内容,并且没有 max capacity 的限制,也是与原始 ByteBuf 使用同一块底层内存,只是读写指针是独立的

  • CompositeByteBuf addComponents(boolean increaseWriterIndex, ByteBuf... buffers):合并多个 ByteBuf

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public static void main(String[] args) {
    ByteBuf buf1 = ByteBufAllocator.DEFAULT.buffer();
    buf1.writeBytes(new byte[]{1, 2, 3, 4, 5});

    ByteBuf buf2 = ByteBufAllocator.DEFAULT.buffer();
    buf1.writeBytes(new byte[]{6, 7, 8, 9, 10});

    CompositeByteBuf buf = ByteBufAllocator.DEFAULT.compositeBuffer();
    // true 表示增加新的 ByteBuf 自动递增 write index, 否则 write index 会始终为 0
    buf.addComponents(true, buf1, buf2);
    }

    CompositeByteBuf 是一个组合的 ByteBuf,内部维护了一个 Component 数组,每个 Component 管理一个 ByteBuf,记录了这个 ByteBuf 相对于整体偏移量等信息,代表着整体中某一段的数据

    • 优点:对外是一个虚拟视图,组合这些 ByteBuf 不会产生内存复制
    • 缺点:复杂了很多,多次操作会带来性能的损耗

深拷贝:

  • ByteBuf copy():将底层内存数据进行深拷贝,因此无论读写,都与原始 ByteBuf 无关

池化相关:

  • Unpooled 是一个工具类,提供了非池化的 ByteBuf 创建、组合、复制等操作

    1
    2
    3
    4
    5
    6
    7
    ByteBuf buf1 = ByteBufAllocator.DEFAULT.buffer(5);
    buf1.writeBytes(new byte[]{1, 2, 3, 4, 5});
    ByteBuf buf2 = ByteBufAllocator.DEFAULT.buffer(5);
    buf2.writeBytes(new byte[]{6, 7, 8, 9, 10});

    // 当包装 ByteBuf 个数超过一个时, 底层使用了 CompositeByteBuf,零拷贝思想
    ByteBuf buf = Unpooled.wrappedBuffer(buf1, buf2);

粘包半包

现象演示

在 TCP 传输中,客户端发送消息时,实际上是将数据写入 TCP 的缓存,此时数据的大小和缓存的大小就会造成粘包和半包

  • 当数据超过 TCP 缓存容量时,就会被拆分成多个包,通过 Socket 多次发送到服务端,服务端每次从缓存中取数据,产生半包问题

  • 当数据小于 TCP 缓存容量时,缓存中可以存放多个包,客户端和服务端一次通信就可能传递多个包,这时候服务端就可能一次读取多个包,产生粘包的问题

代码演示:

  • 客户端代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    public class HelloWorldClient {
    public static void main(String[] args) {
    send();
    }

    private static void send() {
    NioEventLoopGroup worker = new NioEventLoopGroup();
    try {
    Bootstrap bootstrap = new Bootstrap();
    bootstrap.channel(NioSocketChannel.class);
    bootstrap.group(worker);
    bootstrap.handler(new ChannelInitializer<SocketChannel>() {
    @Override
    protected void initChannel(SocketChannel ch) throws Exception {
    ch.pipeline().addLast(new ChannelInboundHandlerAdapter() {
    // 【在连接 channel 建立成功后,会触发 active 方法】
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
    // 发送内容随机的数据包
    Random r = new Random();
    char c = '0';
    ByteBuf buf = ctx.alloc().buffer();
    for (int i = 0; i < 10; i++) {
    byte[] bytes = new byte[10];
    for (int j = 0; j < r.nextInt(9) + 1; j++) {
    bytes[j] = (byte) c;
    }
    c++;
    buf.writeBytes(bytes);
    }
    ctx.writeAndFlush(buf);
    }
    });
    }
    });
    ChannelFuture channelFuture = bootstrap.connect("127.0.0.1", 8080).sync();
    channelFuture.channel().closeFuture().sync();

    } catch (InterruptedException e) {
    log.error("client error", e);
    } finally {
    worker.shutdownGracefully();
    }
    }
    }
  • 服务器代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    public class HelloWorldServer {
    public static void main(String[] args) {
    NioEventLoopGroup boss = new NioEventLoopGroup(1);
    NioEventLoopGroup worker = new NioEventLoopGroup();
    try {
    ServerBootstrap serverBootstrap = new ServerBootstrap();
    serverBootstrap.channel(NioServerSocketChannel.class);
    // 调整系统的接受缓冲区【滑动窗口】
    //serverBootstrap.option(ChannelOption.SO_RCVBUF, 10);
    // 调整 netty 的接受缓冲区(ByteBuf)
    //serverBootstrap.childOption(ChannelOption.RCVBUF_ALLOCATOR,
    // new AdaptiveRecvByteBufAllocator(16, 16, 16));
    serverBootstrap.group(boss, worker);
    serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
    @Override
    protected void initChannel(SocketChannel ch) throws Exception {
    // 【这里可以添加解码器】
    // LoggingHandler 用来打印消息
    ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));
    }
    });
    ChannelFuture channelFuture = serverBootstrap.bind(8080);
    channelFuture.sync();
    channelFuture.channel().closeFuture().sync();
    } catch (InterruptedException e) {
    log.error("server error", e);
    } finally {
    boss.shutdownGracefully();
    worker.shutdownGracefully();
    log.debug("stop");
    }
    }
    }
  • 粘包效果展示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    09:57:27.140 [nioEventLoopGroup-3-1] DEBUG io.netty.handler.logging.LoggingHandler - [id: 0xddbaaef6, L:/127.0.0.1:8080 - R:/127.0.0.1:8701] READ: 100B	// 读了 100 字节,发生粘包
    +-------------------------------------------------+
    | 0 1 2 3 4 5 6 7 8 9 a b c d e f |
    +--------+-------------------------------------------------+----------------+
    |00000000| 30 30 30 30 30 00 00 00 00 00 31 00 00 00 00 00 |00000.....1.....|
    |00000010| 00 00 00 00 32 32 32 32 00 00 00 00 00 00 33 00 |....2222......3.|
    |00000020| 00 00 00 00 00 00 00 00 34 34 00 00 00 00 00 00 |........44......|
    |00000030| 00 00 35 35 35 35 00 00 00 00 00 00 36 36 36 00 |..5555......666.|
    |00000040| 00 00 00 00 00 00 37 37 37 37 00 00 00 00 00 00 |......7777......|
    |00000050| 38 38 38 38 38 00 00 00 00 00 39 39 00 00 00 00 |88888.....99....|
    |00000060| 00 00 00 00 |.... |
    +--------+-------------------------------------------------+----------------+

解决方法:通过调整系统的接受缓冲区的滑动窗口和 Netty 的接受缓冲区保证每条包只含有一条数据,滑动窗口的大小仅决定了 Netty 读取的最小单位,实际每次读取的一般是它的整数倍


解决方案

短连接

发一个包建立一次连接,这样连接建立到连接断开之间就是消息的边界,缺点就是效率很低

客户端代码改造:

1
2
3
4
5
6
7
8
public class HelloWorldClient {
public static void main(String[] args) {
// 分 10 次发送
for (int i = 0; i < 10; i++) {
send();
}
}
}

固定长度

服务器端加入定长解码器,每一条消息采用固定长度。如果是半包消息,会缓存半包消息并等待下个包到达之后进行拼包合并,直到读取一个完整的消息包;如果是粘包消息,空余的位置会进行补 0,会浪费空间

1
2
3
4
5
6
7
8
serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(new FixedLengthFrameDecoder(10));
// LoggingHandler 用来打印消息
ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));
}
});
1
2
3
4
5
6
7
8
9
10
11
12
10:29:06.522 [nioEventLoopGroup-3-1] DEBUG io.netty.handler.logging.LoggingHandler - [id: 0x38a70fbf, L:/127.0.0.1:8080 - R:/127.0.0.1:10144] READ: 10B
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 31 31 00 00 00 00 00 00 00 00 |11........ |
+--------+-------------------------------------------------+----------------+
10:29:06.522 [nioEventLoopGroup-3-1] DEBUG io.netty.handler.logging.LoggingHandler - [id: 0x38a70fbf, L:/127.0.0.1:8080 - R:/127.0.0.1:10144] READ: 10B
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 32 32 32 32 32 32 00 00 00 00 |222222.... |
+--------+-------------------------------------------------+----------------+

分隔符

服务端加入行解码器,默认以 \n\r\n 作为分隔符,如果超出指定长度仍未出现分隔符,则抛出异常:

1
2
3
4
5
6
7
serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(new FixedLengthFrameDecoder(8));
ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));
}
});

客户端在每条消息之后,加入 \n 分隔符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void channelActive(ChannelHandlerContext ctx) throws Exception {
Random r = new Random();
char c = 'a';
ByteBuf buffer = ctx.alloc().buffer();
for (int i = 0; i < 10; i++) {
for (int j = 1; j <= r.nextInt(16)+1; j++) {
buffer.writeByte((byte) c);
}
// 10 代表 '\n'
buffer.writeByte(10);
c++;
}
ctx.writeAndFlush(buffer);
}

预设长度

LengthFieldBasedFrameDecoder 解码器自定义长度解决 TCP 粘包黏包问题

1
2
3
4
5
int maxFrameLength		// 数据最大长度
int lengthFieldOffset // 长度字段偏移量,从第几个字节开始是内容的长度字段
int lengthFieldLength // 长度字段本身的长度
int lengthAdjustment // 长度字段为基准,几个字节后才是内容
int initialBytesToStrip // 从头开始剥离几个字节解码后显示
1
2
3
4
5
6
7
8
9
10
lengthFieldOffset   = 1 (= the length of HDR1)
lengthFieldLength = 2
lengthAdjustment = 1 (= the length of HDR2)
initialBytesToStrip = 3 (= the length of HDR1 + LEN)

BEFORE DECODE (16 bytes) AFTER DECODE (13 bytes)//解码
+------+--------+------+----------------+ +------+----------------+
| HDR1 | Length | HDR2 | Actual Content |----->| HDR2 | Actual Content |
| 0xCA | 0x000C | 0xFE | "HELLO, WORLD" | | 0xFE | "HELLO, WORLD" |
+------+--------+------+----------------+ +------+----------------+

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class LengthFieldDecoderDemo {
public static void main(String[] args) {
EmbeddedChannel channel = new EmbeddedChannel(
// int 占 4 字节,版本号一个字节
new LengthFieldBasedFrameDecoder(1024, 0, 4, 1,5),
new LoggingHandler(LogLevel.DEBUG)
);

// 4 个字节的内容长度, 实际内容
ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer();
send(buffer, "Hello, world");
send(buffer, "Hi!");
// 写出缓存
channel.writeInbound(buffer);
}
// 写入缓存
private static void send(ByteBuf buffer, String content) {
byte[] bytes = content.getBytes(); // 实际内容
int length = bytes.length; // 实际内容长度
buffer.writeInt(length);
buffer.writeByte(1); // 表示版本号
buffer.writeBytes(bytes);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
10:49:59.344 [main] DEBUG io.netty.handler.logging.LoggingHandler - [id: 0xembedded, L:embedded - R:embedded] READ: 12B
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 48 65 6c 6c 6f 2c 20 77 6f 72 6c 64 |Hello, world |
+--------+-------------------------------------------------+----------------+
10:49:59.344 [main] DEBUG io.netty.handler.logging.LoggingHandler - [id: 0xembedded, L:embedded - R:embedded] READ: 3B
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 48 69 21 |Hi! |
+--------+-------------------------------------------------+----------------+

协议设计

HTTP

访问 URL:http://localhost:8080/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class HttpDemo {
public static void main(String[] args) {
NioEventLoopGroup boss = new NioEventLoopGroup();
NioEventLoopGroup worker = new NioEventLoopGroup();
try {
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.channel(NioServerSocketChannel.class);
serverBootstrap.group(boss, worker);
serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));
ch.pipeline().addLast(new HttpServerCodec());
// 只针对某一种类型的请求处理,此处针对 HttpRequest
ch.pipeline().addLast(new SimpleChannelInboundHandler<HttpRequest>() {
@Override
protected void channelRead0(ChannelHandlerContext ctx, HttpRequest msg) {
// 获取请求
log.debug(msg.uri());

// 返回响应
DefaultFullHttpResponse response = new DefaultFullHttpResponse(
msg.protocolVersion(), HttpResponseStatus.OK);

byte[] bytes = "<h1>Hello, world!</h1>".getBytes();

response.headers().setInt(CONTENT_LENGTH, bytes.length);
response.content().writeBytes(bytes);

// 写回响应
ctx.writeAndFlush(response);
}
});
}
});
ChannelFuture channelFuture = serverBootstrap.bind(8080).sync();
channelFuture.channel().closeFuture().sync();
} catch (InterruptedException e) {
log.error("n3.server error", e);
} finally {
boss.shutdownGracefully();
worker.shutdownGracefully();
}
}
}

自定义

处理器代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@Slf4j
public class MessageCodec extends ByteToMessageCodec<Message> {
// 编码
@Override
public void encode(ChannelHandlerContext ctx, Message msg, ByteBuf out) throws Exception {
// 4 字节的魔数
out.writeBytes(new byte[]{1, 2, 3, 4});
// 1 字节的版本,
out.writeByte(1);
// 1 字节的序列化方式 jdk 0 , json 1
out.writeByte(0);
// 1 字节的指令类型
out.writeByte(msg.getMessageType());
// 4 个字节
out.writeInt(msg.getSequenceId());
// 无意义,对齐填充, 1 字节
out.writeByte(0xff);
// 获取内容的字节数组,msg 对象序列化
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(msg);
byte[] bytes = bos.toByteArray();
// 长度
out.writeInt(bytes.length);
// 写入内容
out.writeBytes(bytes);
}

// 解码
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
int magicNum = in.readInt();
byte version = in.readByte();
byte serializerType = in.readByte();
byte messageType = in.readByte();
int sequenceId = in.readInt();
in.readByte();
int length = in.readInt();
byte[] bytes = new byte[length];
in.readBytes(bytes, 0, length);
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes));
Message message = (Message) ois.readObject();
log.debug("{}, {}, {}, {}, {}, {}", magicNum, version, serializerType, messageType, sequenceId, length);
log.debug("{}", message);
out.add(message);
}
}

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static void main(String[] args) throws Exception {
EmbeddedChannel channel = new EmbeddedChannel(new LoggingHandler(), new MessageCodec());
// encode
LoginRequestMessage message = new LoginRequestMessage("zhangsan", "123");
channel.writeOutbound(message);

// decode
ByteBuf buf = ByteBufAllocator.DEFAULT.buffer();
new MessageCodec().encode(null, message, buf);
// 入站
channel.writeInbound(buf);
}
public class LoginRequestMessage extends Message {
private String username;
private String password;
// set + get
}


Sharable

@Sharable 注解的添加时机:

  • 当 handler 不保存状态时,就可以安全地在多线程下被共享

  • 对于编解码器类不能继承 ByteToMessageCodec 或 CombinedChannelDuplexHandler,它们的构造方法对 @Sharable 有限制

    1
    2
    3
    4
    5
    protected ByteToMessageCodec(boolean preferDirect) {
    ensureNotSharable();
    outboundMsgMatcher = TypeParameterMatcher.find(this, ByteToMessageCodec.class, "I");
    encoder = new Encoder(preferDirect);
    }
    1
    2
    3
    4
    5
    6
    protected void ensureNotSharable() {
    // 如果类上有该注解
    if (isSharable()) {
    throw new IllegalStateException();
    }
    }
  • 如果能确保编解码器不会保存状态,可以继承 MessageToMessageCodec 父类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    @Slf4j
    @ChannelHandler.Sharable
    // 必须和 LengthFieldBasedFrameDecoder 一起使用,确保接到的 ByteBuf 消息是完整的
    public class MessageCodecSharable extends MessageToMessageCodec<ByteBuf, Message> {
    @Override
    protected void encode(ChannelHandlerContext ctx, Message msg, List<Object> outList) throws Exception {
    ByteBuf out = ctx.alloc().buffer();
    // 4 字节的魔数
    out.writeBytes(new byte[]{1, 2, 3, 4});
    // ....
    outList.add(out);
    }

    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
    //....
    }
    }

场景优化

空闲检测

连接假死

连接假死就是客户端数据发不出去,服务端也一直收不到数据,保持这种状态,假死的连接占用的资源不能自动释放,而且向假死连接发送数据,得到的反馈是发送超时

解决方案:每隔一段时间就检查这段时间内是否接收到客户端数据,没有就可以判定为连接假死

IdleStateHandler 是 Netty 提供的处理空闲状态的处理器,用来判断是不是读空闲时间或写空闲时间过长

  • 参数一 long readerIdleTime:读空闲,表示多长时间没有读
  • 参数二 long writerIdleTime:写空闲,表示多长时间没有写
  • 参数三 long allIdleTime:读写空闲,表示多长时间没有读写
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024, 12, 4, 0, 0));
ch.pipeline().addLast(new MessageCodec());
// 5s 内如果没有收到 channel 的数据,会触发一个 IdleState#READER_IDLE 事件,
ch.pipeline().addLast(new IdleStateHandler(5, 0, 0));
// ChannelDuplexHandler 【可以同时作为入站和出站】处理器
ch.pipeline().addLast(new ChannelDuplexHandler() {
// 用来触发特殊事件
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception{
IdleStateEvent event = (IdleStateEvent) evt;
// 触发了读空闲事件
if (event.state() == IdleState.READER_IDLE) {
log.debug("已经 5s 没有读到数据了");
ctx.channel().close();
}
}
});
}
}

心跳机制

客户端定时向服务器端发送数据,时间间隔要小于服务器定义的空闲检测的时间间隔,就能防止误判连接假死,这就是心跳机制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
bootstrap.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024, 12, 4, 0, 0));
ch.pipeline().addLast(new MessageCodec());
// 3s 内如果没有向服务器写数据,会触发一个 IdleState#WRITER_IDLE 事件
ch.pipeline().addLast(new IdleStateHandler(0, 3, 0));
// ChannelDuplexHandler 可以同时作为入站和出站处理器
ch.pipeline().addLast(new ChannelDuplexHandler() {
// 用来触发特殊事件
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
IdleStateEvent event = (IdleStateEvent) evt;
// 触发了写空闲事件
if (event.state() == IdleState.WRITER_IDLE) {
// 3s 没有写数据了,【发送一个心跳包】
ctx.writeAndFlush(new PingMessage());
}
}
});
}
}

序列化

普通方式

序列化,反序列化主要用在消息正文的转换上

  • 序列化时,需要将 Java 对象变为要传输的数据(可以是 byte[],或 json 等,最终都需要变成 byte[])
  • 反序列化时,需要将传入的正文数据还原成 Java 对象,便于处理

代码实现:

  • 抽象一个 Serializer 接口

    1
    2
    3
    4
    5
    6
    public interface Serializer {
    // 反序列化方法
    <T> T deserialize(Class<T> clazz, byte[] bytes);
    // 序列化方法
    <T> byte[] serialize(T object);
    }
  • 提供两个实现

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    enum SerializerAlgorithm implements Serializer {
    // Java 实现
    Java {
    @Override
    public <T> T deserialize(Class<T> clazz, byte[] bytes) {
    try {
    ObjectInputStream in =
    new ObjectInputStream(new ByteArrayInputStream(bytes));
    Object object = in.readObject();
    return (T) object;
    } catch (IOException | ClassNotFoundException e) {
    throw new RuntimeException("SerializerAlgorithm.Java 反序列化错误", e);
    }
    }

    @Override
    public <T> byte[] serialize(T object) {
    try {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    new ObjectOutputStream(out).writeObject(object);
    return out.toByteArray();
    } catch (IOException e) {
    throw new RuntimeException("SerializerAlgorithm.Java 序列化错误", e);
    }
    }
    },
    // Json 实现(引入了 Gson 依赖)
    Json {
    @Override
    public <T> T deserialize(Class<T> clazz, byte[] bytes) {
    return new Gson().fromJson(new String(bytes, StandardCharsets.UTF_8), clazz);
    }

    @Override
    public <T> byte[] serialize(T object) {
    return new Gson().toJson(object).getBytes(StandardCharsets.UTF_8);
    }
    };

    // 需要从协议的字节中得到是哪种序列化算法
    public static SerializerAlgorithm getByInt(int type) {
    SerializerAlgorithm[] array = SerializerAlgorithm.values();
    if (type < 0 || type > array.length - 1) {
    throw new IllegalArgumentException("超过 SerializerAlgorithm 范围");
    }
    return array[type];
    }
    }

ProtoBuf

基本介绍

Codec(编解码器)的组成部分有两个:Decoder(解码器)和 Encoder(编码器)。Encoder 负责把业务数据转换成字节码数据,Decoder 负责把字节码数据转换成业务数据

Protobuf 是 Google 发布的开源项目,全称 Google Protocol Buffers ,是一种轻便高效的结构化数据存储格式,可以用于结构化数据串行化,或者说序列化。很适合做数据存储或 RPC(远程过程调用 remote procedure call)数据交换格式。目前很多公司从 HTTP + Json 转向 TCP + Protobuf ,效率会更高

Protobuf 是以 message 的方式来管理数据,支持跨平台、跨语言(客户端和服务器端可以是不同的语言编写的),高性能、高可靠性

工作过程:使用 Protobuf 编译器自动生成代码,Protobuf 是将类的定义使用 .proto 文件进行描述,然后通过 protoc.exe 编译器根据 .proto 自动生成 .java 文件


代码实现
  • 单个 message:

    1
    2
    3
    4
    5
    6
    7
    syntax = "proto3"; 								// 版本
    option java_outer_classname = "StudentPOJO"; // 生成的外部类名,同时也是文件名

    message Student { // 在 StudentPOJO 外部类种生成一个内部类 Student,是真正发送的 POJO 对象
    int32 id = 1; // Student 类中有一个属性:名字为 id 类型为 int32(protobuf类型) ,1表示属性序号,不是值
    string name = 2;
    }

    编译 protoc.exe --java_out=.Student.proto(cmd 窗口输入) 将生成的 StudentPOJO 放入到项目使用

    Server 端:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    new ServerBootstrap() //...
    .childHandler(new ChannelInitializer<SocketChannel>() { // 创建一个通道初始化对象
    // 给pipeline 设置处理器
    @Override
    protected void initChannel(SocketChannel ch) throws Exception {
    // 在pipeline加入ProtoBufDecoder,指定对哪种对象进行解码
    ch.pipeline().addLast("decoder", new ProtobufDecoder(
    StudentPOJO.Student.getDefaultInstance()));
    ch.pipeline().addLast(new NettyServerHandler());
    }
    });
    }

    Client 端:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    new Bootstrap().group(group) 			// 设置线程组
    .channel(NioSocketChannel.class) // 设置客户端通道的实现类(反射)
    .handler(new ChannelInitializer<SocketChannel>() {
    @Override
    protected void initChannel(SocketChannel ch) throws Exception {
    // 在pipeline中加入 ProtoBufEncoder
    ch.pipeline().addLast("encoder", new ProtobufEncoder());
    ch.pipeline().addLast(new NettyClientHandler()); // 加入自定义的业务处理器
    }
    });
  • 多个 message:Protobuf 可以使用 message 管理其他的 message。假设某个项目需要传输 20 个对象,可以在一个文件里定义 20 个 message,最后再用一个总的 message 来决定在实际传输时真正需要传输哪一个对象

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    syntax = "proto3";
    option optimize_for = SPEED; // 加快解析
    option java_package="com.atguigu.netty.codec2"; // 指定生成到哪个包下
    option java_outer_classname="MyDataInfo"; // 外部类名, 文件名

    message MyMessage {
    // 定义一个枚举类型,DataType 如果是 0 则表示一个 Student 对象实例,DataType 这个名称自定义
    enum DataType {
    StudentType = 0; //在 proto3 要求 enum 的编号从 0 开始
    WorkerType = 1;
    }

    // 用 data_type 来标识传的是哪一个枚举类型,这里才真正开始定义 Message 的数据类型
    DataType data_type = 1; // 所有后面的数字都只是编号而已

    // oneof 关键字,表示每次枚举类型进行传输时,限制最多只能传输一个对象。
    // dataBody名称也是自定义的
    // MyMessage 里出现的类型只有两个 DataType 类型,Student 或者 Worker 类型,在真正传输的时候只会有一个出现
    oneof dataBody {
    Student student = 2; //注意这后面的数字也都只是编号而已,上面DataType data_type = 1 占了第一个序号了
    Worker worker = 3;
    }


    }

    message Student {
    int32 id = 1; // Student类的属性
    string name = 2; //
    }
    message Worker {
    string name=1;
    int32 age=2;
    }

    编译:

    Server 端:

    1
    ch.pipeline().addLast("decoder", new ProtobufDecoder(MyDataInfo.MyMessage.getDefaultInstance()));

    Client 端:

    1
    pipeline.addLast("encoder", new ProtobufEncoder());

长连接

HTTP 协议是无状态的,浏览器和服务器间的请求响应一次,下一次会重新创建连接。实现基于 WebSocket 的长连接的全双工的交互,改变 HTTP 协议多次请求的约束

开发需求:

  • 实现长连接,服务器与浏览器相互通信客户端
  • 浏览器和服务器端会相互感知,比如服务器关闭了,浏览器会感知,同样浏览器关闭了,服务器会感知

代码实现:

  • WebSocket:

    • WebSocket 的数据是以帧(frame)形式传递,WebSocketFrame 下面有六个子类,代表不同的帧格式

    • 浏览器请求 URL:ws://localhost:8080/xxx

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    public class MyWebSocket {
    public static void main(String[] args) throws Exception {
    // 创建两个线程组
    EventLoopGroup bossGroup = new NioEventLoopGroup(1);
    EventLoopGroup workerGroup = new NioEventLoopGroup();
    try {

    ServerBootstrap serverBootstrap = new ServerBootstrap();
    serverBootstrap.group(bossGroup, workerGroup);
    serverBootstrap.channel(NioServerSocketChannel.class);
    serverBootstrap.handler(new LoggingHandler(LogLevel.INFO));
    serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
    @Override
    protected void initChannel(SocketChannel ch) throws Exception {
    ChannelPipeline pipeline = ch.pipeline();

    // 基于 http 协议,使用 http 的编码和解码器
    pipeline.addLast(new HttpServerCodec());
    // 是以块方式写,添加 ChunkedWriteHandler 处理器
    pipeline.addLast(new ChunkedWriteHandler());

    // http 数据在传输过程中是分段, HttpObjectAggregator 就是可以将多个段聚合
    // 这就就是为什么,当浏览器发送大量数据时,就会发出多次 http 请求
    pipeline.addLast(new HttpObjectAggregator(8192));

    // WebSocketServerProtocolHandler 核心功能是【将 http 协议升级为 ws 协议】,保持长连接
    pipeline.addLast(new WebSocketServerProtocolHandler("/hello"));

    // 自定义的handler ,处理业务逻辑
    pipeline.addLast(new MyTextWebSocketFrameHandler());
    }
    });

    // 启动服务器
    ChannelFuture channelFuture = serverBootstrap.bind(8080).sync();
    channelFuture.channel().closeFuture().sync();

    } finally {
    bossGroup.shutdownGracefully();
    workerGroup.shutdownGracefully();
    }
    }
    }
  • 处理器:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    public class MyTextWebSocketFrameHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
    // TextWebSocketFrame 类型,表示一个文本帧(frame)
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
    System.out.println("服务器收到消息 " + msg.text());
    // 回复消息
    ctx.writeAndFlush(new TextWebSocketFrame("服务器时间" + LocalDateTime.now() + " " + msg.text()));
    }

    // 当web客户端连接后, 触发方法
    @Override
    public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
    // id 表示唯一的值,LongText 是唯一的 ShortText 不是唯一
    System.out.println("handlerAdded 被调用" + ctx.channel().id().asLongText());
    System.out.println("handlerAdded 被调用" + ctx.channel().id().asShortText());
    }

    @Override
    public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
    System.out.println("handlerRemoved 被调用" + ctx.channel().id().asLongText());
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
    System.out.println("异常发生 " + cause.getMessage());
    ctx.close(); // 关闭连接
    }
    }
  • HTML:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8">
    <title>Title</title>
    </head>
    <body>
    <script>
    var socket;
    // 判断当前浏览器是否支持websocket
    if(window.WebSocket) {
    //go on
    socket = new WebSocket("ws://localhost:8080/hello");
    //相当于channelReado, ev 收到服务器端回送的消息
    socket.onmessage = function (ev) {
    var rt = document.getElementById("responseText");
    rt.value = rt.value + "\n" + ev.data;
    }

    //相当于连接开启(感知到连接开启)
    socket.onopen = function (ev) {
    var rt = document.getElementById("responseText");
    rt.value = "连接开启了.."
    }

    //相当于连接关闭(感知到连接关闭)
    socket.onclose = function (ev) {

    var rt = document.getElementById("responseText");
    rt.value = rt.value + "\n" + "连接关闭了.."
    }
    } else {
    alert("当前浏览器不支持websocket")
    }

    // 发送消息到服务器
    function send(message) {
    // 先判断socket是否创建好
    if(!window.socket) {
    return;
    }
    if(socket.readyState == WebSocket.OPEN) {
    // 通过socket 发送消息
    socket.send(message)
    } else {
    alert("连接没有开启");
    }
    }
    </script>
    <form onsubmit="return false">
    <textarea name="message" style="height: 300px; width: 300px"></textarea>
    <input type="button" value="发生消息" onclick="send(this.form.message.value)">
    <textarea id="responseText" style="height: 300px; width: 300px"></textarea>
    <input type="button" value="清空内容" onclick="document.getElementById('responseText').value=''">
    </form>
    </body>
    </html>

参数调优

CONNECT

参数配置方式:

  • 客户端通过 .option() 方法配置参数,给 SocketChannel 配置参数
  • 服务器端:
    • new ServerBootstrap().option(): 给 ServerSocketChannel 配置参数
    • new ServerBootstrap().childOption():给 SocketChannel 配置参数

CONNECT_TIMEOUT_MILLIS 参数:

  • 属于 SocketChannal 参数

  • 在客户端建立连接时,如果在指定毫秒内无法连接,会抛出 timeout 异常

  • SO_TIMEOUT 主要用在阻塞 IO,阻塞 IO 中 accept,read 等都是无限等待的,如果不希望永远阻塞,可以调整超时时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ConnectionTimeoutTest {
public static void main(String[] args) {
NioEventLoopGroup group = new NioEventLoopGroup();
try {
Bootstrap bootstrap = new Bootstrap()
.group(group)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
.channel(NioSocketChannel.class)
.handler(new LoggingHandler());
ChannelFuture future = bootstrap.connect("127.0.0.1", 8080);
future.sync().channel().closeFuture().sync();
} catch (Exception e) {
e.printStackTrace();
log.debug("timeout");
} finally {
group.shutdownGracefully();
}
}
}

SO_BACKLOG

属于 ServerSocketChannal 参数,通过 option(ChannelOption.SO_BACKLOG, value) 来设置大小

在 Linux 2.2 之前,backlog 大小包括了两个队列的大小,在 2.2 之后,分别用下面两个参数来控制

  • sync queue:半连接队列,大小通过 /proc/sys/net/ipv4/tcp_max_syn_backlog 指定,在 syncookies 启用的情况下,逻辑上没有最大值限制
  • accept queue:全连接队列,大小通过 /proc/sys/net/core/somaxconn 指定,在使用 listen 函数时,内核会根据传入的 backlog 参数与系统参数,取二者的较小值。如果 accpet queue 队列满了,server 将发送一个拒绝连接的错误信息到 client


其他参数

ALLOCATOR:属于 SocketChannal 参数,用来分配 ByteBuf, ctx.alloc()

RCVBUF_ALLOCATOR:属于 SocketChannal 参数

  • 控制 Netty 接收缓冲区大小
  • 负责入站数据的分配,决定入站缓冲区的大小(并可动态调整),统一采用 direct 直接内存,具体池化还是非池化由 allocator 决定

RocketMQ

基本介绍

消息队列

消息队列是一种先进先出的数据结构,常见的应用场景:

  • 应用解耦:系统的耦合性越高,容错性就越低

    实例:用户创建订单后,耦合调用库存系统、物流系统、支付系统,任何一个子系统出了故障都会造成下单异常,影响用户使用体验。使用消息队列解耦合,比如物流系统发生故障,需要几分钟恢复,将物流系统要处理的数据缓存到消息队列中,用户的下单操作正常完成。等待物流系统正常后处理存在消息队列中的订单消息即可,终端系统感知不到物流系统发生过几分钟故障

  • 流量削峰:应用系统如果遇到系统请求流量的瞬间猛增,有可能会将系统压垮,使用消息队列可以将大量请求缓存起来,分散到很长一段时间处理,这样可以提高系统的稳定性和用户体验

  • 数据分发:让数据在多个系统更加之间进行流通,数据的产生方不需要关心谁来使用数据,只需要将数据发送到消息队列,数据使用方直接在消息队列中直接获取数据

参考视频:https://www.bilibili.com/video/BV1L4411y7mn


安装测试

安装需要 Java 环境,下载解压后进入安装目录,进行启动:

  • 启动 NameServer

    1
    2
    3
    4
    # 1.启动 NameServer
    nohup sh bin/mqnamesrv &
    # 2.查看启动日志
    tail -f ~/logs/rocketmqlogs/namesrv.log

    RocketMQ 默认的虚拟机内存较大,需要编辑如下两个配置文件,修改 JVM 内存大小

    1
    2
    3
    # 编辑runbroker.sh和runserver.sh修改默认JVM大小
    vi runbroker.sh
    vi runserver.sh

    参考配置:JAVA_OPT=”${JAVA_OPT} -server -Xms256m -Xmx256m -Xmn128m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=320m”

  • 启动 Broker

    1
    2
    3
    4
    # 1.启动 Broker
    nohup sh bin/mqbroker -n localhost:9876 autoCreateTopicEnable=true &
    # 2.查看启动日志
    tail -f ~/logs/rocketmqlogs/broker.log
  • 发送消息:

    1
    2
    3
    4
    # 1.设置环境变量
    export NAMESRV_ADDR=localhost:9876
    # 2.使用安装包的 Demo 发送消息
    sh bin/tools.sh org.apache.rocketmq.example.quickstart.Producer
  • 接受消息:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
      # 1.设置环境变量
    export NAMESRV_ADDR=localhost:9876
    # 2.接收消息
    sh bin/tools.sh org.apache.rocketmq.example.quickstart.Consumer

    * 关闭 RocketMQ:

    ```sh
    # 1.关闭 NameServer
    sh bin/mqshutdown namesrv
    # 2.关闭 Broker
    sh bin/mqshutdown broker



    ***



    ### 相关概念

    RocketMQ 主要由 Producer、Broker、Consumer 三部分组成,其中 Producer 负责生产消息,Consumer 负责消费消息,Broker 负责存储消息,NameServer 负责管理 Broker

    * 代理服务器(Broker Server):消息中转角色,负责**存储消息、转发消息**。在 RocketMQ 系统中负责接收从生产者发送来的消息并存储、同时为消费者的拉取请求作准备,也存储消息相关的元数据,包括消费者组、消费进度偏移和主题和队列消息等
    * 名字服务(Name Server):充当**路由消息**的提供者。生产者或消费者能够通过名字服务查找各主题相应的 Broker IP 列表
    * 消息生产者(Producer):负责**生产消息**,把业务应用系统里产生的消息发送到 Broker 服务器。RocketMQ 提供多种发送方式,同步发送、异步发送、顺序发送、单向发送,同步和异步方式均需要 Broker 返回确认信息,单向发送不需要;可以通过 MQ 的负载均衡模块选择相应的 Broker 集群队列进行消息投递,投递的过程支持快速失败并且低延迟
    * 消息消费者(Consumer):负责**消费消息**,一般是后台系统负责异步消费,一个消息消费者会从 Broker 服务器拉取消息、并将其提供给应用程序。从用户应用的角度而提供了两种消费形式:
    * 拉取式消费(Pull Consumer):应用通主动调用 Consumer 的拉消息方法从 Broker 服务器拉消息,主动权由应用控制,一旦获取了批量消息,应用就会启动消费过程
    * 推动式消费(Push Consumer):该模式下 Broker 收到数据后会主动推送给消费端,实时性较高
    * 生产者组(Producer Group):同一类 Producer 的集合,发送同一类消息且发送逻辑一致。如果发送的是事务消息且原始生产者在发送之后崩溃,**则 Broker 服务器会联系同一生产者组的其他生产者实例以提交或回溯消费**
    * 消费者组(Consumer Group):同一类 Consumer 的集合,消费者实例必须订阅完全相同的 Topic,消费同一类消息且消费逻辑一致。消费者组使得在消息消费方面更容易的实现负载均衡和容错。RocketMQ 支持两种消息模式:
    * 集群消费(Clustering):相同 Consumer Group 的每个 Consumer 实例平均分摊消息
    * 广播消费(Broadcasting):相同 Consumer Group 的每个 Consumer 实例都接收全量的消息

    每个 Broker 可以存储多个 Topic 的消息,每个 Topic 的消息也可以分片存储于不同的 Broker,Message Queue(消息队列)是用于存储消息的物理地址,每个 Topic 中的消息地址存储于多个 Message Queue 中

    * 主题(Topic):表示一类消息的集合,每个主题包含若干条消息,每条消息只属于一个主题,是 RocketMQ 消息订阅的基本单位

    * 消息(Message):消息系统所传输信息的物理载体,生产和消费数据的最小单位,每条消息必须属于一个主题。RocketMQ 中每个消息拥有唯一的 Message ID,且可以携带具有业务标识的 Key,系统提供了通过 Message ID 和 Key 查询消息的功能

    * 标签(Tag):为消息设置的标志,用于同一主题下区分不同类型的消息。标签能够有效地保持代码的清晰度和连贯性,并优化 RocketMQ 提供的查询系统,消费者可以根据 Tag 实现对不同子主题的不同消费逻辑,实现更好的扩展性

    * 普通顺序消息(Normal Ordered Message):消费者通过同一个消息队列(Topic 分区)收到的消息是有顺序的,不同消息队列收到的消息则可能是无顺序的

    * 严格顺序消息(Strictly Ordered Message):消费者收到的所有消息均是有顺序的



    官方文档:https://github.com/apache/rocketmq/tree/master/docs/cn(基础知识部分的笔记参考官方文档编写)





    ****





    ## 消息操作

    ### 基本样例

    #### 订阅发布

    消息的发布是指某个生产者向某个 Topic 发送消息,消息的订阅是指某个消费者关注了某个 Topic 中带有某些 Tag 的消息,进而从该 Topic 消费数据

    导入 MQ 客户端依赖:

    ```xml
    <dependency>
    <groupId>org.apache.rocketmq</groupId>
    <artifactId>rocketmq-client</artifactId>
    <version>4.4.0</version>
    </dependency>

消息发送者步骤分析:

  1. 创建消息生产者 Producer,并制定生产者组名
  2. 指定 Nameserver 地址
  3. 启动 Producer
  4. 创建消息对象,指定主题 Topic、Tag 和消息体
  5. 发送消息
  6. 关闭生产者 Producer

消息消费者步骤分析:

  1. 创建消费者 Consumer,制定消费者组名
  2. 指定 Nameserver 地址
  3. 订阅主题 Topic 和 Tag
  4. 设置回调函数,处理消息
  5. 启动消费者 Consumer

官方文档:https://github.com/apache/rocketmq/blob/master/docs/cn/RocketMQ_Example.md


发送消息

同步发送

使用 RocketMQ 发送三种类型的消息:同步消息、异步消息和单向消息,其中前两种消息是可靠的,因为会有发送是否成功的应答

这种可靠性同步地发送方式使用的比较广泛,比如:重要的消息通知,短信通知

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class SyncProducer {
public static void main(String[] args) throws Exception {
// 实例化消息生产者Producer
DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
// 设置NameServer的地址
producer.setNamesrvAddr("localhost:9876");
// 启动Producer实例
producer.start();
for (int i = 0; i < 100; i++) {
// 创建消息,并指定Topic,Tag和消息体
Message msg = new Message(
"TopicTest" /* Topic */,
"TagA" /* Tag */,
("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET) /* Message body */);

// 发送消息到一个Broker
SendResult sendResult = producer.send(msg);
// 通过sendResult返回消息是否成功送达
System.out.printf("%s%n", sendResult);
}
// 如果不再发送消息,关闭Producer实例。
producer.shutdown();
}
}

异步发送

异步消息通常用在对响应时间敏感的业务场景,即发送端不能容忍长时间地等待 Broker 的响应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class AsyncProducer {
public static void main(String[] args) throws Exception {
// 实例化消息生产者Producer
DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
// 设置NameServer的地址
producer.setNamesrvAddr("localhost:9876");
// 启动Producer实例
producer.start();
producer.setRetryTimesWhenSendAsyncFailed(0);

int messageCount = 100;
// 根据消息数量实例化倒计时计算器
final CountDownLatch2 countDownLatch = new CountDownLatch2(messageCount);
for (int i = 0; i < messageCount; i++) {
final int index = i;
// 创建消息,并指定Topic,Tag和消息体
Message msg = new Message("TopicTest", "TagA", "OrderID188",
"Hello world".getBytes(RemotingHelper.DEFAULT_CHARSET));

// SendCallback接收异步返回结果的回调
producer.send(msg, new SendCallback() {
// 发送成功回调函数
@Override
public void onSuccess(SendResult sendResult) {
countDownLatch.countDown();
System.out.printf("%-10d OK %s %n", index, sendResult.getMsgId());
}

@Override
public void onException(Throwable e) {
countDownLatch.countDown();
System.out.printf("%-10d Exception %s %n", index, e);
e.printStackTrace();
}
});
}
// 等待5s
countDownLatch.await(5, TimeUnit.SECONDS);
// 如果不再发送消息,关闭Producer实例。
producer.shutdown();
}
}

单向发送

单向发送主要用在不特别关心发送结果的场景,例如日志发送

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class OnewayProducer {
public static void main(String[] args) throws Exception{
// 实例化消息生产者Producer
DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
// 设置NameServer的地址
producer.setNamesrvAddr("localhost:9876");
// 启动Producer实例
producer.start();
for (int i = 0; i < 100; i++) {
// 创建消息,并指定Topic,Tag和消息体
Message msg = new Message("TopicTest","TagA",
("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET));
// 发送单向消息,没有任何返回结果
producer.sendOneway(msg);
}
// 如果不再发送消息,关闭Producer实例。
producer.shutdown();
}
}

消费消息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Consumer {
public static void main(String[] args) throws InterruptedException, MQClientException {
// 实例化消费者
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name");
// 设置NameServer的地址
consumer.setNamesrvAddr("localhost:9876");

// 订阅一个或者多个Topic,以及Tag来过滤需要消费的消息
consumer.subscribe("TopicTest", "*");
// 注册消息监听器,回调实现类来处理从broker拉取回来的消息
consumer.registerMessageListener(new MessageListenerConcurrently() {
// 接受消息内容
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
System.out.printf("%s Receive New Messages: %s %n", Thread.currentThread().getName(), msgs);
// 标记该消息已经被成功消费
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
// 启动消费者实例
consumer.start();
System.out.printf("Consumer Started.%n");
}
}

顺序消息

原理解析

消息有序指的是一类消息消费时,能按照发送的顺序来消费。例如:一个订单产生了三条消息分别是订单创建、订单付款、订单完成。消费时要按照这个顺序消费才能有意义,但是同时订单之间是可以并行消费的,RocketMQ 可以严格的保证消息有序。

顺序消息分为全局顺序消息与分区顺序消息,

  • 全局顺序:对于指定的一个 Topic,所有消息按照严格的先入先出(FIFO)的顺序进行发布和消费,适用于性能要求不高,所有的消息严格按照 FIFO 原则进行消息发布和消费的场景
  • 分区顺序:对于指定的一个 Topic,所有消息根据 Sharding key 进行分区,同一个分组内的消息按照严格的 FIFO 顺序进行发布和消费。Sharding key 是顺序消息中用来区分不同分区的关键字段,和普通消息的 Key 是完全不同的概念,适用于性能要求高的场景

在默认的情况下消息发送会采取 Round Robin 轮询方式把消息发送到不同的 queue(分区队列),而消费消息是从多个 queue 上拉取消息,这种情况发送和消费是不能保证顺序。但是如果控制发送的顺序消息只依次发送到同一个 queue 中,消费的时候只从这个 queue 上依次拉取,则就保证了顺序。当发送和消费参与的 queue 只有一个,则是全局有序;如果多个queue 参与,则为分区有序,即相对每个 queue,消息都是有序的


代码实现

一个订单的顺序流程是:创建、付款、推送、完成,订单号相同的消息会被先后发送到同一个队列中,消费时同一个 OrderId 获取到的肯定是同一个队列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
public class Producer {
public static void main(String[] args) throws Exception {
DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
producer.setNamesrvAddr("127.0.0.1:9876");
producer.start();
// 标签集合
String[] tags = new String[]{"TagA", "TagC", "TagD"};

// 订单列表
List<OrderStep> orderList = new Producer().buildOrders();

Date date = new Date();
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String dateStr = sdf.format(date);
for (int i = 0; i < 10; i++) {
// 加个时间前缀
String body = dateStr + " Hello RocketMQ " + orderList.get(i);
Message msg = new Message("OrderTopic", tags[i % tags.length], "KEY" + i, body.getBytes());
/**
* 参数一:消息对象
* 参数二:消息队列的选择器
* 参数三:选择队列的业务标识(订单 ID)
*/
SendResult sendResult = producer.send(msg, new MessageQueueSelector() {
@Override
/**
* mqs:队列集合
* msg:消息对象
* arg:业务标识的参数
*/
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
Long id = (Long) arg;
long index = id % mqs.size(); // 根据订单id选择发送queue
return mqs.get((int) index);
}
}, orderList.get(i).getOrderId());//订单id

System.out.println(String.format("SendResult status:%s, queueId:%d, body:%s",
sendResult.getSendStatus(),
sendResult.getMessageQueue().getQueueId(),
body));
}

producer.shutdown();
}

// 订单的步骤
private static class OrderStep {
private long orderId;
private String desc;
// set + get
}

// 生成模拟订单数据
private List<OrderStep> buildOrders() {
List<OrderStep> orderList = new ArrayList<OrderStep>();

OrderStep orderDemo = new OrderStep();
orderDemo.setOrderId(15103111039L);
orderDemo.setDesc("创建");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103111065L);
orderDemo.setDesc("创建");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103111039L);
orderDemo.setDesc("付款");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103117235L);
orderDemo.setDesc("创建");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103111065L);
orderDemo.setDesc("付款");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103117235L);
orderDemo.setDesc("付款");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103111065L);
orderDemo.setDesc("完成");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103111039L);
orderDemo.setDesc("推送");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103117235L);
orderDemo.setDesc("完成");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103111039L);
orderDemo.setDesc("完成");
orderList.add(orderDemo);

return orderList;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 顺序消息消费,带事务方式(应用可控制Offset什么时候提交)
public class ConsumerInOrder {
public static void main(String[] args) throws Exception {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name_3");
consumer.setNamesrvAddr("127.0.0.1:9876");
// 设置Consumer第一次启动是从队列头部开始消费还是队列尾部开始消费
// 如果非第一次启动,那么按照上次消费的位置继续消费
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
// 订阅三个tag
consumer.subscribe("OrderTopic", "TagA || TagC || TagD");
consumer.registerMessageListener(new MessageListenerOrderly() {
Random random = new Random();
@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
context.setAutoCommit(true);
for (MessageExt msg : msgs) {
// 可以看到每个queue有唯一的consume线程来消费, 订单对每个queue(分区)有序
System.out.println("consumeThread=" + Thread.currentThread().getName() + "queueId=" + msg.getQueueId() + ", content:" + new String(msg.getBody()));
}
return ConsumeOrderlyStatus.SUCCESS;
}
});
consumer.start();
System.out.println("Consumer Started.");
}
}

延时消息

原理解析

定时消息(延迟队列)是指消息发送到 Broker 后,不会立即被消费,等待特定时间投递给真正的 Topic

RocketMQ 并不支持任意时间的延时,需要设置几个固定的延时等级,从 1s 到 2h 分别对应着等级 1 到 18,消息消费失败会进入延时消息队列,消息发送时间与设置的延时等级和重试次数有关,详见代码 SendMessageProcessor.java

1
private String messageDelayLevel = "1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h";

Broker 可以配置 messageDelayLevel,该属性是 Broker 的属性,不属于某个 Topic

发消息时,可以设置延迟等级 msg.setDelayLevel(level),level 有以下三种情况:

  • level == 0:消息为非延迟消息
  • 1<=level<=maxLevel:消息延迟特定时间,例如 level==1,延迟 1s
  • level > maxLevel:则 level== maxLevel,例如 level==20,延迟 2h

定时消息会暂存在名为 SCHEDULE_TOPIC_XXXX 的 Topic 中,并根据 delayTimeLevel 存入特定的 queue,队列的标识 queueId = delayTimeLevel – 1,即一个 queue 只存相同延迟的消息,保证具有相同发送延迟的消息能够顺序消费。Broker 会为每个延迟级别提交一个定时任务,调度地消费 SCHEDULE_TOPIC_XXXX,将消息写入真实的 Topic

注意:定时消息在第一次写入和调度写入真实 Topic 时都会计数,因此发送数量、tps 都会变高


代码实现

提交了一个订单就可以发送一个延时消息,1h 后去检查这个订单的状态,如果还是未付款就取消订单释放库存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ScheduledMessageProducer {
public static void main(String[] args) throws Exception {
// 实例化一个生产者来产生延时消息
DefaultMQProducer producer = new DefaultMQProducer("ExampleProducerGroup");
producer.setNamesrvAddr("127.0.0.1:9876");
// 启动生产者
producer.start();
int totalMessagesToSend = 100;
for (int i = 0; i < totalMessagesToSend; i++) {
Message message = new Message("DelayTopic", ("Hello scheduled message " + i).getBytes());
// 设置延时等级3,这个消息将在10s之后发送(现在只支持固定的几个时间,详看delayTimeLevel)
message.setDelayTimeLevel(3);
// 发送消息
producer.send(message);
}
// 关闭生产者
producer.shutdown();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ScheduledMessageConsumer {
public static void main(String[] args) throws Exception {
// 实例化消费者
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("ExampleConsumer");
consumer.setNamesrvAddr("127.0.0.1:9876");
// 订阅Topics
consumer.subscribe("DelayTopic", "*");
// 注册消息监听者
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> messages, ConsumeConcurrentlyContext context) {
for (MessageExt message : messages) {
// 打印延迟的时间段
System.out.println("Receive message[msgId=" + message.getMsgId() + "] " + (System.currentTimeMillis() - message.getBornTimestamp()) + "ms later");}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
// 启动消费者
consumer.start();
}
}

批量消息

批量发送消息能显著提高传递小消息的性能,限制是这些批量消息应该有相同的 topic,相同的 waitStoreMsgOK,而且不能是延时消息,并且这一批消息的总大小不应超过 4MB

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Producer {

public static void main(String[] args) throws Exception {
DefaultMQProducer producer = new DefaultMQProducer("ExampleProducerGroup")
producer.setNamesrvAddr("127.0.0.1:9876");
//启动producer
producer.start();

List<Message> msgs = new ArrayList<Message>();
// 创建消息对象,指定主题Topic、Tag和消息体
Message msg1 = new Message("BatchTopic", "Tag1", ("Hello World" + 1).getBytes());
Message msg2 = new Message("BatchTopic", "Tag1", ("Hello World" + 2).getBytes());
Message msg3 = new Message("BatchTopic", "Tag1", ("Hello World" + 3).getBytes());

msgs.add(msg1);
msgs.add(msg2);
msgs.add(msg3);

// 发送消息
SendResult result = producer.send(msgs);
System.out.println("发送结果:" + result);
// 关闭生产者producer
producer.shutdown();
}
}

当发送大批量数据时,可能不确定消息是否超过了大小限制(4MB),所以需要将消息列表分割一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
public class ListSplitter implements Iterator<List<Message>> {
private final int SIZE_LIMIT = 1024 * 1024 * 4;
private final List<Message> messages;
private int currIndex;

public ListSplitter(List<Message> messages) {
this.messages = messages;
}

@Override
public boolean hasNext() {
return currIndex < messages.size();
}

@Override
public List<Message> next() {
int startIndex = getStartIndex();
int nextIndex = startIndex;
int totalSize = 0;
for (; nextIndex < messages.size(); nextIndex++) {
Message message = messages.get(nextIndex);
int tmpSize = calcMessageSize(message);
// 单个消息超过了最大的限制
if (tmpSize + totalSize > SIZE_LIMIT) {
break;
} else {
totalSize += tmpSize;
}
}
List<Message> subList = messages.subList(startIndex, nextIndex);
currIndex = nextIndex;
return subList;
}

private int getStartIndex() {
Message currMessage = messages.get(currIndex);
int tmpSize = calcMessageSize(currMessage);
while (tmpSize > SIZE_LIMIT) {
currIndex += 1;
Message message = messages.get(curIndex);
tmpSize = calcMessageSize(message);
}
return currIndex;
}

private int calcMessageSize(Message message) {
int tmpSize = message.getTopic().length() + message.getBody().length;
Map<String, String> properties = message.getProperties();
for (Map.Entry<String, String> entry : properties.entrySet()) {
tmpSize += entry.getKey().length() + entry.getValue().length();
}
tmpSize = tmpSize + 20; // 增加⽇日志的开销20字节
return tmpSize;
}

public static void main(String[] args) {
//把大的消息分裂成若干个小的消息
ListSplitter splitter = new ListSplitter(messages);
while (splitter.hasNext()) {
try {
List<Message> listItem = splitter.next();
producer.send(listItem);
} catch (Exception e) {
e.printStackTrace();
//处理error
}
}
}
}

过滤消息

基本语法

RocketMQ 定义了一些基本语法来支持过滤特性,可以很容易地扩展:

  • 数值比较,比如:>,>=,<,<=,BETWEEN,=
  • 字符比较,比如:=,<>,IN
  • IS NULL 或者 IS NOT NULL
  • 逻辑符号 AND,OR,NOT

常量支持类型为:

  • 数值,比如 123,3.1415
  • 字符,比如 ‘abc’,必须用单引号包裹起来
  • NULL,特殊的常量
  • 布尔值,TRUE 或 FALSE

只有使用 push 模式的消费者才能用使用 SQL92 标准的 sql 语句,接口如下:

1
public void subscribe(final String topic, final MessageSelector messageSelector)

例如:消费者接收包含 TAGA 或 TAGB 或 TAGC 的消息

1
2
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("CID_EXAMPLE");
consumer.subscribe("TOPIC", "TAGA || TAGB || TAGC");

原理解析

RocketMQ 分布式消息队列的消息过滤方式是在 Consumer 端订阅消息时再做消息过滤的,所以是在 Broker 端实现的,优点是减少了对于 Consumer 无用消息的网络传输,缺点是增加了 Broker 的负担,而且实现相对复杂

RocketMQ 在 Producer 端写入消息和在 Consumer 端订阅消息采用分离存储的机制实现,Consumer 端订阅消息是需要通过 ConsumeQueue 这个消息消费的逻辑队列拿到一个索引,然后再从 CommitLog 里面读取真正的消息实体内容

ConsumeQueue 的存储结构如下,有 8 个字节存储的 Message Tag 的哈希值,基于 Tag 的消息过滤就是基于这个字段

  • Tag 过滤:Consumer 端订阅消息时指定 Topic 和 TAG,然后将订阅请求构建成一个 SubscriptionData,发送一个 Pull 消息的请求给 Broker 端。Broker 端用这些数据先构建一个 MessageFilter,然后传给文件存储层 Store。Store 从 ConsumeQueue 读取到一条记录后,会用它记录的消息 tag hash 值去做过滤。因为在服务端只是根据 hashcode 进行判断,无法精确对 tag 原始字符串进行过滤,所以消费端拉取到消息后,还需要对消息的原始 tag 字符串进行比对,如果不同,则丢弃该消息,不进行消息消费

  • SQL92 过滤:工作流程和 Tag 过滤大致一样,只是在 Store 层的具体过滤方式不一样。真正的 SQL expression 的构建和执行由 rocketmq-filter 模块负责,每次过滤都去执行 SQL 表达式会影响效率,所以 RocketMQ 使用了 BloomFilter 来避免了每次都去执行


代码实现

发送消息时,通过 putUserProperty 来设置消息的属性,SQL92 的表达式上下文为消息的属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Producer {
public static void main(String[] args) throws Exception {
DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
producer.setNamesrvAddr("127.0.0.1:9876");
producer.start();
for (int i = 0; i < 10; i++) {
Message msg = new Message("FilterTopic", "tag",
("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET));
// 设置一些属性
msg.putUserProperty("i", String.valueOf(i));
SendResult sendResult = producer.send(msg);
}
producer.shutdown();
}
}

使用 SQL 筛选过滤消息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Consumer {
public static void main(String[] args) throws Exception {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name");
consumer.setNamesrvAddr("127.0.0.1:9876");
// 过滤属性大于 5 的消息
consumer.subscribe("FilterTopic", MessageSelector.bySql("i>5"));

// 设置回调函数,处理消息
consumer.registerMessageListener(new MessageListenerConcurrently() {
//接受消息内容
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
for (MessageExt msg : msgs) {
System.out.println("consumeThread=" + Thread.currentThread().getName() + "," + new String(msg.getBody()));
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
// 启动消费者consumer
consumer.start();
}
}

事务消息

工作流程

RocketMQ 支持分布式事务消息,采用了 2PC 的思想来实现了提交事务消息,同时增加一个补偿逻辑来处理二阶段超时或者失败的消息,如下图所示:

事务消息的大致方案分为两个流程:正常事务消息的发送及提交、事务消息的补偿流程

  1. 事务消息发送及提交:

    • 发送消息(Half 消息),服务器将消息的主题和队列改为半消息状态,并放入半消息队列

    • 服务端响应消息写入结果(如果写入失败,此时 Half 消息对业务不可见)

    • 根据发送结果执行本地事务

    • 根据本地事务状态执行 Commit 或者 Rollback

  1. 补偿机制:用于解决消息 Commit 或者 Rollback 发生超时或者失败的情况,比如出现网络问题

    • Broker 服务端通过对比 Half 消息和 Op 消息,对未确定状态的消息推进 CheckPoint
    • 没有 Commit/Rollback 的事务消息,服务端根据根据半消息的生产者组,到 ProducerManager 中获取生产者(同一个 Group 的 Producer)的会话通道,发起一次回查(单向请求
    • Producer 收到回查消息,检查事务消息状态表内对应的本地事务的状态
    • 根据本地事务状态,重新 Commit 或者 Rollback

    RocketMQ 并不会无休止的进行事务状态回查,最大回查 15 次,如果 15 次回查还是无法得知事务状态,则默认回滚该消息,

    回查服务:TransactionalMessageCheckService#run


两阶段

一阶段

事务消息相对普通消息最大的特点就是一阶段发送的消息对用户是不可见的,因为对于 Half 消息,会备份原消息的主题与消息消费队列,然后改变主题为 RMQ_SYS_TRANS_HALF_TOPIC,由于消费组未订阅该主题,故消费端无法消费 Half 类型的消息

RocketMQ 会开启一个定时任务,从 Topic 为 RMQ_SYS_TRANS_HALF_TOPIC 中拉取消息进行消费,根据生产者组获取一个服务提供者发送回查事务状态请求,根据事务状态来决定是提交或回滚消息

RocketMQ 的具体实现策略:如果写入的是事务消息,对消息的 Topic 和 Queue 等属性进行替换,同时将原来的 Topic 和 Queue 信息存储到消息的属性中,因为消息的主题被替换,所以消息不会转发到该原主题的消息消费队列,消费者无法感知消息的存在,不会消费


二阶段

一阶段写入不可见的消息后,二阶段操作:

  • 如果执行 Commit 操作,则需要让消息对用户可见,构建出 Half 消息的索引。一阶段的 Half 消息写到一个特殊的 Topic,构建索引时需要读取出 Half 消息,然后通过一次普通消息的写入操作将 Topic 和 Queue 替换成真正的目标 Topic 和 Queue,生成一条对用户可见的消息。其实就是利用了一阶段存储的消息的内容,在二阶段时恢复出一条完整的普通消息,然后走一遍消息写入流程

  • 如果是 Rollback 则需要撤销一阶段的消息,因为消息本就不可见,所以并不需要真正撤销消息(实际上 RocketMQ 也无法去删除一条消息,因为是顺序写文件的)。RocketMQ 为了区分这条消息没有确定状态的消息,采用 Op 消息标识已经确定状态的事务消息(Commit 或者 Rollback)

事务消息无论是 Commit 或者 Rollback 都会记录一个 Op 操作,两者的区别是 Commit 相对于 Rollback 在写入 Op 消息前将原消息的主题和队列恢复。如果一条事务消息没有对应的 Op 消息,说明这个事务的状态还无法确定(可能是二阶段失败了)

RocketMQ 将 Op 消息写入到全局一个特定的 Topic 中,通过源码中的方法 TransactionalMessageUtil.buildOpTopic(),这个主题是一个内部的 Topic(像 Half 消息的 Topic 一样),不会被用户消费。Op 消息的内容为对应的 Half 消息的存储的 Offset,这样通过 Op 消息能索引到 Half 消息


基本使用

使用方式

事务消息共有三种状态,提交状态、回滚状态、中间状态:

  • TransactionStatus.CommitTransaction:提交事务,允许消费者消费此消息。
  • TransactionStatus.RollbackTransaction:回滚事务,代表该消息将被删除,不允许被消费
  • TransactionStatus.Unknown:中间状态,代表需要检查消息队列来确定状态

使用限制:

  1. 事务消息不支持延时消息和批量消息
  2. Broker 配置文件中的参数 transactionTimeout 为特定时间,事务消息将在特定时间长度之后被检查。当发送事务消息时,还可以通过设置用户属性 CHECK_IMMUNITY_TIME_IN_SECONDS 来改变这个限制,该参数优先于 transactionTimeout 参数
  3. 为了避免单个消息被检查太多次而导致半队列消息累积,默认将单个消息的检查次数限制为 15 次,开发者可以通过 Broker 配置文件的 transactionCheckMax 参数来修改此限制。如果已经检查某条消息超过 N 次(N = transactionCheckMax), 则 Broker 将丢弃此消息,在默认情况下会打印错误日志。可以通过重写 AbstractTransactionalMessageCheckListener 类来修改这个行为
  4. 事务性消息可能不止一次被检查或消费
  5. 提交给用户的目标主题消息可能会失败,可以查看日志的记录。事务的高可用性通过 RocketMQ 本身的高可用性机制来保证,如果希望事务消息不丢失、并且事务完整性得到保证,可以使用同步的双重写入机制
  6. 事务消息的生产者 ID 不能与其他类型消息的生产者 ID 共享。与其他类型的消息不同,事务消息允许反向查询,MQ 服务器能通过消息的生产者 ID 查询到消费者

代码实现

实现事务的监听接口,当发送半消息成功时:

  • executeLocalTransaction 方法来执行本地事务,返回三个事务状态之一
  • checkLocalTransaction 方法检查本地事务状态,响应消息队列的检查请求,返回三个事务状态之一
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class TransactionListenerImpl implements TransactionListener {
private AtomicInteger transactionIndex = new AtomicInteger(0);
private ConcurrentHashMap<String, Integer> localTrans = new ConcurrentHashMap<>();

@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
int value = transactionIndex.getAndIncrement();
int status = value % 3;
// 将事务ID和状态存入 map 集合
localTrans.put(msg.getTransactionId(), status);
return LocalTransactionState.UNKNOW;
}

@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
// 从 map 集合读出当前事务对应的状态
Integer status = localTrans.get(msg.getTransactionId());
if (null != status) {
switch (status) {
case 0:
return LocalTransactionState.UNKNOW;
case 1:
return LocalTransactionState.COMMIT_MESSAGE;
case 2:
return LocalTransactionState.ROLLBACK_MESSAGE;
}
}
return LocalTransactionState.COMMIT_MESSAGE;
}
}

使用 TransactionMQProducer 类创建事务性生产者,并指定唯一的 ProducerGroup,就可以设置自定义线程池来处理这些检查请求,执行本地事务后,需要根据执行结果对消息队列进行回复

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class Producer {
public static void main(String[] args) throws MQClientException, InterruptedException {
// 创建消息生产者
TransactionMQProducer producer = new TransactionMQProducer("please_rename_unique_group_name");
ExecutorService executorService = new ThreadPoolExecutor(2, 5, 100, TimeUnit.SECONDS);
producer.setExecutorService(executorService);

// 创建事务监听器
TransactionListener transactionListener = new TransactionListenerImpl();
// 生产者的监听器
producer.setTransactionListener(transactionListener);
// 启动生产者
producer.start();
String[] tags = new String[] {"TagA", "TagB", "TagC", "TagD", "TagE"};
for (int i = 0; i < 10; i++) {
try {
Message msg = new Message("TransactionTopic", tags[i % tags.length], "KEY" + i,
("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET));
// 发送消息
SendResult sendResult = producer.sendMessageInTransaction(msg, null);
System.out.printf("%s%n", sendResult);
Thread.sleep(10);
} catch (MQClientException | UnsupportedEncodingException e) {
e.printStackTrace();
}
}
//Thread.sleep(1000000);
//producer.shutdown();暂时不关闭
}
}

消费者代码和前面的实例相同的


系统特性

工作流程

模块介绍

NameServer 是一个简单的 Topic 路由注册中心,支持 Broker 的动态注册与发现,生产者或消费者能够通过名字服务查找各主题相应的 Broker IP 列表

NameServer 主要包括两个功能:

  • Broker 管理,NameServer 接受 Broker 集群的注册信息,保存下来作为路由信息的基本数据,提供心跳检测机制检查 Broker 是否还存活,每 10 秒清除一次两小时没有活跃的 Broker
  • 路由信息管理,每个 NameServer 将保存关于 Broker 集群的整个路由信息和用于客户端查询的队列信息,然后 Producer 和 Conumser 通过 NameServer 就可以知道整个 Broker 集群的路由信息,从而进行消息的投递和消费

NameServer 特点:

  • NameServer 通常是集群的方式部署,各实例间相互不进行信息通讯
  • Broker 向每一台 NameServer(集群)注册自己的路由信息,所以每个 NameServer 实例上面都保存一份完整的路由信息
  • 当某个 NameServer 因某种原因下线了,Broker 仍可以向其它 NameServer 同步其路由信息

BrokerServer 主要负责消息的存储、投递和查询以及服务高可用保证,在 RocketMQ 系统中接收从生产者发送来的消息并存储、同时为消费者的拉取请求作准备,也存储消息相关的元数据,包括消费者组、消费进度偏移和主题和队列消息等

Broker 包含了以下几个重要子模块:

  • Remoting Module:整个 Broker 的实体,负责处理来自 Clients 端的请求

  • Client Manager:负责管理客户端(Producer/Consumer)和维护 Consumer 的 Topic 订阅信息

  • Store Service:提供方便简单的 API 接口处理消息存储到物理硬盘和查询功能

  • HA Service:高可用服务,提供 Master Broker 和 Slave Broker 之间的数据同步功能

  • Index Service:根据特定的 Message key 对投递到 Broker 的消息进行索引服务,以提供消息的快速查询


总体流程

RocketMQ 的工作流程:

  • 启动 NameServer 监听端口,等待 Broker、Producer、Consumer 连上来,相当于一个路由控制中心
  • Broker 启动,跟所有的 NameServer 保持长连接,每隔 30s 时间向 NameServer 上报 Topic 路由信息(心跳包)。心跳包中包含当前 Broker 信息(IP、端口等)以及存储所有 Topic 信息。注册成功后,NameServer 集群中就有 Topic 跟 Broker 的映射关系
  • 收发消息前,先创建 Topic,创建 Topic 时需要指定该 Topic 要存储在哪些 Broker 上,也可以在发送消息时自动创建 Topic
  • Producer 启动时先跟 NameServer 集群中的其中一台建立长连接,并从 NameServer 中获取当前发送的 Topic 存在哪些 Broker 上,同时 Producer 会默认每隔 30s 向 NameServer 定时拉取一次路由信息
  • Producer 发送消息时,根据消息的 Topic 从本地缓存的 TopicPublishInfoTable 获取路由信息,如果没有则会从 NameServer 上重新拉取并更新,轮询队列列表并选择一个队列 MessageQueue,然后与队列所在的 Broker 建立长连接,向 Broker 发消息
  • Consumer 跟 Producer 类似,跟其中一台 NameServer 建立长连接,定时获取路由信息,根据当前订阅 Topic 存在哪些 Broker 上,直接跟 Broker 建立连接通道,在完成客户端的负载均衡后,选择其中的某一个或者某几个 MessageQueue 来拉取消息并进行消费

生产消费

At least Once:至少一次,指每个消息必须投递一次,Consumer 先 Pull 消息到本地,消费完成后才向服务器返回 ACK,如果没有消费一定不会 ACK 消息

回溯消费:指 Consumer 已经消费成功的消息,由于业务上需求需要重新消费,Broker 在向 Consumer 投递成功消息后,消息仍然需要保留。并且重新消费一般是按照时间维度,例如由于 Consumer 系统故障,恢复后需要重新消费 1 小时前的数据,RocketMQ 支持按照时间回溯消费,时间维度精确到毫秒

分布式队列因为有高可靠性的要求,所以数据要进行持久化存储

  1. 消息生产者发送消息
  2. MQ 收到消息,将消息进行持久化,在存储中新增一条记录
  3. 返回 ACK 给生产者
  4. MQ push 消息给对应的消费者,然后等待消费者返回 ACK
  5. 如果消息消费者在指定时间内成功返回 ACK,那么 MQ 认为消息消费成功,在存储中删除消息;如果 MQ 在指定时间内没有收到 ACK,则认为消息消费失败,会尝试重新 push 消息,重复执行 4、5、6 步骤
  6. MQ 删除消息


存储机制

存储结构

RocketMQ 中 Broker 负责存储消息转发消息,所以以下的结构是存储在 Broker Server 上的,生产者和消费者与 Broker 进行消息的收发是通过主题对应的 Message Queue 完成,类似于通道

RocketMQ 消息的存储是由 ConsumeQueue 和 CommitLog 配合完成 的,CommitLog 是消息真正的物理存储文件,ConsumeQueue 是消息的逻辑队列,类似数据库的索引节点,存储的是指向物理存储的地址。每个 Topic 下的每个 Message Queue 都有一个对应的 ConsumeQueue 文件

每条消息都会有对应的索引信息,Consumer 通过 ConsumeQueue 这个结构来读取消息实体内容

  • CommitLog:消息主体以及元数据的存储主体,存储 Producer 端写入的消息内容,消息内容不是定长的。消息主要是顺序写入日志文件,单个文件大小默认 1G,偏移量代表下一次写入的位置,当文件写满了就继续写入下一个文件
  • ConsumerQueue:消息消费队列,存储消息在 CommitLog 的索引。RocketMQ 消息消费时要遍历 CommitLog 文件,并根据主题 Topic 检索消息,这是非常低效的。引入 ConsumeQueue 作为消费消息的索引,保存了指定 Topic 下的队列消息在 CommitLog 中的起始物理偏移量 offset,消息大小 size 和消息 Tag 的 HashCode 值,每个 ConsumeQueue 文件大小约 5.72M
  • IndexFile:为了消息查询提供了一种通过 Key 或时间区间来查询消息的方法,通过 IndexFile 来查找消息的方法不影响发送与消费消息的主流程。IndexFile 的底层存储为在文件系统中实现的 HashMap 结构,故 RocketMQ 的索引文件其底层实现为 hash 索引

RocketMQ 采用的是混合型的存储结构,即为 Broker 单个实例下所有的队列共用一个日志数据文件(CommitLog)来存储。混合型存储结构(多个 Topic 的消息实体内容都存储于一个 CommitLog 中)针对 Producer 和 Consumer 分别采用了数据和索引部分相分离的存储结构,Producer 发送消息至 Broker 端,然后 Broker 端使用同步或者异步的方式对消息刷盘持久化,保存至 CommitLog 中。只要消息被持久化至磁盘文件 CommitLog 中,Producer 发送的消息就不会丢失,Consumer 也就肯定有机会去消费这条消息

服务端支持长轮询模式,当消费者无法拉取到消息后,可以等下一次消息拉取,Broker 允许等待 30s 的时间,只要这段时间内有新消息到达,将直接返回给消费端。RocketMQ 的具体做法是,使用 Broker 端的后台服务线程 ReputMessageService 不停地分发请求并异步构建 ConsumeQueue(逻辑消费队列)和 IndexFile(索引文件)数据


内存映射

操作系统分为用户态和内核态,文件操作、网络操作需要涉及这两种形态的切换,需要进行数据复制。一台服务器把本机磁盘文件的内容发送到客户端,分为两个步骤:

  • read:读取本地文件内容

  • write:将读取的内容通过网络发送出去

补充:Prog → NET → I/O → 零拷贝部分的笔记详解相关内容

通过使用 mmap 的方式,可以省去向用户态的内存复制,RocketMQ 充分利用零拷贝技术,提高消息存盘和网络发送的速度

RocketMQ 通过 MappedByteBuffer 对文件进行读写操作,利用了 NIO 中的 FileChannel 模型将磁盘上的物理文件直接映射到用户态的内存地址中,将对文件的操作转化为直接对内存地址进行操作,从而极大地提高了文件的读写效率

MappedByteBuffer 内存映射的方式限制一次只能映射 1.5~2G 的文件至用户态的虚拟内存,所以 RocketMQ 默认设置单个 CommitLog 日志数据文件为 1G。RocketMQ 的文件存储使用定长结构来存储,方便一次将整个文件映射至内存


页面缓存

页缓存(PageCache)是 OS 对文件的缓存,每一页的大小通常是 4K,用于加速对文件的读写。因为 OS 将一部分的内存用作 PageCache,所以程序对文件进行顺序读写的速度几乎接近于内存的读写速度

  • 对于数据的写入,OS 会先写入至 Cache 内,随后通过异步的方式由 pdflush 内核线程将 Cache 内的数据刷盘至物理磁盘上
  • 对于数据的读取,如果一次读取文件时出现未命中 PageCache 的情况,OS 从物理磁盘上访问读取文件的同时,会顺序对其他相邻块的数据文件进行预读取(局部性原理,最大 128K)

在 RocketMQ 中,ConsumeQueue 逻辑消费队列存储的数据较少,并且是顺序读取,在 PageCache 机制的预读取作用下,Consume Queue 文件的读性能几乎接近读内存,即使在有消息堆积情况下也不会影响性能。但是 CommitLog 消息存储的日志数据文件读取内容时会产生较多的随机访问读取,严重影响性能。选择合适的系统 IO 调度算法和固态硬盘,比如设置调度算法为 Deadline,随机读的性能也会有所提升


刷盘机制

两种持久化的方案:

  • 关系型数据库 DB:IO 读写性能比较差,如果 DB 出现故障,则 MQ 的消息就无法落盘存储导致线上故障,可靠性不高
  • 文件系统:消息刷盘至所部署虚拟机/物理机的文件系统来做持久化,分为异步刷盘和同步刷盘两种模式。消息刷盘为消息存储提供了一种高效率、高可靠性和高性能的数据持久化方式,除非部署 MQ 机器本身或是本地磁盘挂了,一般不会出现无法持久化的问题

RocketMQ 采用文件系统的方式,无论同步还是异步刷盘,都使用顺序 IO,因为磁盘的顺序读写要比随机读写快很多

  • 同步刷盘:只有在消息真正持久化至磁盘后 RocketMQ 的 Broker 端才会真正返回给 Producer 端一个成功的 ACK 响应,保障 MQ 消息的可靠性,但是性能上会有较大影响,一般适用于金融业务应用该模式较多

  • 异步刷盘:利用 OS 的 PageCache,只要消息写入内存 PageCache 即可将成功的 ACK 返回给 Producer 端,降低了读写延迟,提高了 MQ 的性能和吞吐量。消息刷盘采用后台异步线程提交的方式进行,当内存里的消息量积累到一定程度时,触发写磁盘动作

通过 Broker 配置文件里的 flushDiskType 参数设置采用什么方式,可以配置成 SYNC_FLUSH、ASYNC_FLUSH 中的一个

官方文档:https://github.com/apache/rocketmq/blob/master/docs/cn/design.md


集群设计

集群模式

常用的以下几种模式:

  • 单 Master 模式:这种方式风险较大,一旦 Broker 重启或者宕机,会导致整个服务不可用

  • 多 Master 模式:一个集群无 Slave,全是 Master

    • 优点:配置简单,单个 Master 宕机或重启维护对应用无影响,在磁盘配置为 RAID10 时,即使机器宕机不可恢复情况下,由于 RAID10 磁盘非常可靠,消息也不会丢(异步刷盘丢失少量消息,同步刷盘一条不丢),性能最高

    • 缺点:单台机器宕机期间,这台机器上未被消费的消息在机器恢复之前不可订阅,消息实时性会受到影响

  • 多 Master 多 Slave 模式(同步):每个 Master 配置一个 Slave,有多对 Master-Slave,HA 采用同步双写方式,即只有主备都写成功,才向应用返回成功

    • 优点:数据与服务都无单点故障,Master 宕机情况下,消息无延迟,服务可用性与数据可用性都非常高
    • 缺点:性能比异步复制略低(大约低 10% 左右),发送单个消息的 RT 略高,目前不能实现主节点宕机,备机自动切换为主机
  • 多 Master 多 Slave 模式(异步):HA 采用异步复制的方式,会造成主备有短暂的消息延迟(毫秒级别)

    • 优点:即使磁盘损坏,消息丢失的非常少,且消息实时性不会受影响,同时 Master 宕机后,消费者仍然可以从 Slave 消费,而且此过程对应用透明,不需要人工干预,性能同多 Master 模式几乎一样
    • 缺点:Master 宕机,磁盘损坏情况下会丢失少量消息

集群架构

RocketMQ 网络部署特点:

  • NameServer 是一个几乎无状态节点,节点之间相互独立,无任何信息同步

  • Broker 部署相对复杂,Broker 分为 Master 与 Slave,Master 可以部署多个,一个 Master 可以对应多个 Slave,但是一个 Slave 只能对应一个 Master,Master 与 Slave 的对应关系通过指定相同 BrokerName、不同 BrokerId 来定义,BrokerId 为 0 是 Master,非 0 表示 Slave。每个 Broker 与 NameServer 集群中的所有节点建立长连接,定时注册 Topic 信息到所有 NameServer

    说明:部署架构上也支持一 Master 多 Slave,但只有 BrokerId=1 的从服务器才会参与消息的读负载(读写分离)

  • Producer 与 NameServer 集群中的其中一个节点(随机选择)建立长连接,定期从 NameServer 获取 Topic 路由信息,并向提供 Topic 服务的 Master 建立长连接,且定时向 Master 发送心跳。Producer 完全无状态,可集群部署

  • Consumer 与 NameServer 集群中的其中一个节点(随机选择)建立长连接,定期从 NameServer 获取 Topic 路由信息,并向提供 Topic 服务的 Master、Slave 建立长连接,且定时向 Master、Slave 发送心跳

    Consumer 既可以从 Master 订阅消息,也可以从 Slave 订阅消息,在向 Master 拉取消息时,Master 服务器会根据拉取偏移量与最大偏移量的距离(判断是否读老消息,产生读 I/O),以及从服务器是否可读等因素建议下一次是从 Master 还是 Slave 拉取

官方文档:https://github.com/apache/rocketmq/blob/master/docs/cn/architecture.md


高可用性

NameServer 节点是无状态的,且各个节点直接的数据是一致的,部分 NameServer 不可用也可以保证 MQ 服务正常运行

BrokerServer 的高可用通过 Master 和 Slave 的配合:

  • Slave 只负责读,当 Master 不可用,对应的 Slave 仍能保证消息被正常消费
  • 配置多组 Master-Slave 组,其他的 Master-Slave 组也会保证消息的正常发送和消费
  • 目前不支持把 Slave 自动转成 Master,需要手动停止 Slave 角色的 Broker,更改配置文件,用新的配置文件启动 Broker

生产端的高可用:在创建 Topic 的时候,把 Topic 的多个 Message Queue 创建在多个 Broker 组上(相同 Broker 名称,不同 brokerId 的机器),当一个 Broker 组的 Master 不可用后,其他组的 Master 仍然可用,Producer 仍然可以发送消息

消费端的高可用:在 Consumer 的配置文件中,并不需要设置是从 Master Broker 读还是从 Slave 读,当 Master 不可用或者繁忙的时候,Consumer 会被自动切换到从 Slave 读。有了自动切换的机制,当一个 Master 机器出现故障后,Consumer 仍然可以从 Slave 读取消息,不影响 Consumer 程序,达到了消费端的高可用性


主从复制

如果一个 Broker 组有 Master 和 Slave,消息需要从 Master 复制到 Slave 上,有同步和异步两种复制方式:

  • 同步复制方式:Master 和 Slave 均写成功后才反馈给客户端写成功状态。在同步复制方式下,如果 Master 出故障, Slave 上有全部的备份数据,容易恢复,但是同步复制会增大数据写入延迟,降低系统吞吐量

  • 异步复制方式:只要 Master 写成功,即可反馈给客户端写成功状态,系统拥有较低的延迟和较高的吞吐量,但是如果 Master 出了故障,有些数据因为没有被写入 Slave,有可能会丢失

同步复制和异步复制是通过 Broker 配置文件里的 brokerRole 参数进行设置的,可以设置成 ASYNC_MASTE、RSYNC_MASTER、SLAVE 三个值中的一个

一般把刷盘机制配置成 ASYNC_FLUSH,主从复制为 SYNC_MASTER,这样即使有一台机器出故障,仍然能保证数据不丢

RocketMQ 支持消息的高可靠,影响消息可靠性的几种情况:

  1. Broker 非正常关闭
  2. Broker 异常 Crash
  3. OS Crash
  4. 机器掉电,但是能立即恢复供电情况
  5. 机器无法开机(可能是 CPU、主板、内存等关键设备损坏)
  6. 磁盘设备损坏

前四种情况都属于硬件资源可立即恢复情况,RocketMQ 在这四种情况下能保证消息不丢,或者丢失少量数据(依赖刷盘方式)

后两种属于单点故障,且无法恢复,一旦发生,在此单点上的消息全部丢失。RocketMQ 在这两种情况下,通过主从异步复制,可保证 99% 的消息不丢,但是仍然会有极少量的消息可能丢失。通过同步双写技术可以完全避免单点,但是会影响性能,适合对消息可靠性要求极高的场合,RocketMQ 从 3.0 版本开始支持同步双写


负载均衡

生产端

RocketMQ 中的负载均衡可以分为 Producer 端发送消息时候的负载均衡和 Consumer 端订阅消息的负载均衡

Producer 端在发送消息时,会先根据 Topic 找到指定的 TopicPublishInfo,在获取了 TopicPublishInfo 路由信息后,RocketMQ 的客户端在默认方式调用 selectOneMessageQueue() 方法从 TopicPublishInfo 中的 messageQueueList 中选择一个队列 MessageQueue 进行发送消息

默认会轮询所有的 Message Queue 发送,以让消息平均落在不同的 queue 上,而由于 queue可以散落在不同的 Broker,所以消息就发送到不同的 Broker 下,图中箭头线条上的标号代表顺序,发布方会把第一条消息发送至 Queue 0,然后第二条消息发送至 Queue 1,以此类推:

容错策略均在 MQFaultStrategy 这个类中定义,有一个 sendLatencyFaultEnable 开关变量:

  • 如果开启,会在随机(只有初始化索引变量时才随机,正常都是递增)递增取模的基础上,再过滤掉 not available 的 Broker
  • 如果关闭,采用随机递增取模的方式选择一个队列(MessageQueue)来发送消息

LatencyFaultTolerance 机制是实现消息发送高可用的核心关键所在,对之前失败的,按一定的时间做退避。例如上次请求的 latency 超过 550Lms,就退避 3000Lms;超过 1000L,就退避 60000L


消费端

在 RocketMQ 中,Consumer 端的两种消费模式(Push/Pull)都是基于拉模式来获取消息的,而在 Push 模式只是对 Pull 模式的一种封装,其本质实现为消息拉取线程在从服务器拉取到一批消息,提交到消息消费线程池后,又继续向服务器再次尝试拉取消息,如果未拉取到消息,则延迟一下又继续拉取

在两种基于拉模式的消费方式(Push/Pull)中,均需要 Consumer 端在知道从 Broker 端的哪一个消息队列中去获取消息,所以在 Consumer 端来做负载均衡,即 Broker 端中多个 MessageQueue 分配给同一个 Consumer Group 中的哪些 Consumer 消费

  • 广播模式下要求一条消息需要投递到一个消费组下面所有的消费者实例,所以不存在负载均衡,在实现上,Consumer 分配 queue 时,所有 Consumer 都分到所有的 queue。

  • 在集群消费模式下,每条消息只需要投递到订阅这个 Topic 的 Consumer Group 下的一个实例即可,RocketMQ 采用主动拉取的方式拉取并消费消息,在拉取的时候需要明确指定拉取哪一条 Message Queue

集群模式下,每当消费者实例的数量有变更,都会触发一次所有实例的负载均衡,这时候会按照 queue 的数量和实例的数量平均分配 queue 给每个实例。默认的分配算法是 AllocateMessageQueueAveragely:

还有一种平均的算法是 AllocateMessageQueueAveragelyByCircle,以环状轮流均分 queue 的形式:

集群模式下,queue 都是只允许分配一个实例,如果多个实例同时消费一个 queue 的消息,由于拉取哪些消息是 Consumer 主动控制的,会导致同一个消息在不同的实例下被消费多次

通过增加 Consumer 实例去分摊 queue 的消费,可以起到水平扩展的消费能力的作用。而当有实例下线时,会重新触发负载均衡,这时候原来分配到的 queue 将分配到其他实例上继续消费。但是如果 Consumer 实例的数量比 Message Queue 的总数量还多的话,多出来的 Consumer 实例将无法分到 queue,也就无法消费到消息,也就无法起到分摊负载的作用了,所以需要控制让 queue 的总数量大于等于 Consumer 的数量


原理解析

在 Consumer 启动后,会通过定时任务不断地向 RocketMQ 集群中的所有 Broker 实例发送心跳包。Broker 端在收到 Consumer 的心跳消息后,会将它维护在 ConsumerManager 的本地缓存变量 consumerTable,同时并将封装后的客户端网络通道信息保存在本地缓存变量 channelInfoTable 中,为 Consumer 端的负载均衡提供可以依据的元数据信息

Consumer 端实现负载均衡的核心类 RebalanceImpl

在 Consumer 实例的启动流程中的会启动 MQClientInstance 实例,完成负载均衡服务线程 RebalanceService 的启动(每隔 20s 执行一次负载均衡),RebalanceService 线程的 run() 方法最终调用的是 RebalanceImpl 类的 rebalanceByTopic() 方法,该方法是实现 Consumer 端负载均衡的核心。rebalanceByTopic() 方法会根据广播模式还是集群模式做不同的逻辑处理。主要看集群模式:

  • 从 rebalanceImpl 实例的本地缓存变量 topicSubscribeInfoTable 中,获取该 Topic 主题下的消息消费队列集合 mqSet

  • 根据 Topic 和 consumerGroup 为参数调用 mQClientFactory.findConsumerIdList() 方法向 Broker 端发送获取该消费组下消费者 ID 列表的 RPC 通信请求(Broker 端基于前面 Consumer 端上报的心跳包数据而构建的 consumerTable 做出响应返回,业务请求码 GET_CONSUMER_LIST_BY_GROUP

  • 先对 Topic 下的消息消费队列、消费者 ID 排序,然后用消息队列分配策略算法(默认是消息队列的平均分配算法),计算出待拉取的消息队列。平均分配算法类似于分页的算法,将所有 MessageQueue 排好序类似于记录,将所有消费端 Consumer 排好序类似页数,并求出每一页需要包含的平均 size 和每个页面记录的范围 range,最后遍历整个 range 而计算出当前 Consumer 端应该分配到的记录(这里即为 MessageQueue)

  • 调用 updateProcessQueueTableInRebalance() 方法,先将分配到的消息队列集合 mqSet 与 processQueueTable 做一个过滤比对

  • processQueueTable 标注的红色部分,表示与分配到的消息队列集合 mqSet 互不包含,将这些队列设置 Dropped 属性为 true,然后查看这些队列是否可以移除出 processQueueTable 缓存变量。具体执行 removeUnnecessaryMessageQueue() 方法,即每隔 1s 查看是否可以获取当前消费处理队列的锁,拿到的话返回 true;如果等待 1s 后,仍然拿不到当前消费处理队列的锁则返回 false。如果返回 true,则从 processQueueTable 缓存变量中移除对应的 Entry

  • processQueueTable 的绿色部分,表示与分配到的消息队列集合 mqSet 的交集,判断该 ProcessQueue 是否已经过期了,在 Pull 模式的不用管,如果是 Push 模式的,设置 Dropped 属性为 true,并且调用 removeUnnecessaryMessageQueue() 方法,像上面一样尝试移除 Entry

  • 为过滤后的消息队列集合 mqSet 中每个 MessageQueue 创建 ProcessQueue 对象存入 RebalanceImpl 的 processQueueTable 队列中(其中调用 RebalanceImpl 实例的 computePullFromWhere(MessageQueue mq) 方法获取该 MessageQueue 对象的下一个进度消费值 offset,随后填充至接下来要创建的 pullRequest 对象属性中),并创建拉取请求对象 pullRequest 添加到拉取列表 pullRequestList 中,最后执行 dispatchPullRequest() 方法,将 Pull 消息的请求对象 PullRequest 放入 PullMessageService 服务线程的阻塞队列 pullRequestQueue 中,待该服务线程取出后向 Broker 端发起 Pull 消息的请求

    对比下 RebalancePushImpl 和 RebalancePullImpl 两个实现类的 dispatchPullRequest() 方法,RebalancePullImpl 类里面的该方法为空

消息消费队列在同一消费组不同消费者之间的负载均衡,其核心设计理念是在一个消息消费队列在同一时间只允许被同一消费组内的一个消费者消费,一个消息消费者能同时消费多个消息队列


消息查询

查询方式

RocketMQ 支持按照两种维度进行消息查询:按照 Message ID 查询消息、按照 Message Key 查询消息

  • RocketMQ 中的 MessageID 的长度总共有 16 字节,其中包含了消息存储主机地址(IP 地址和端口),消息 Commit Log offset

    实现方式:Client 端从 MessageID 中解析出 Broker 的地址(IP 地址和端口)和 Commit Log 的偏移地址,封装成一个 RPC 请求后通过 Remoting 通信层发送(业务请求码 VIEW_MESSAGE_BY_ID)。Broker 端走的是 QueryMessageProcessor,读取消息的过程用其中的 CommitLog 的 offset 和 size 去 CommitLog 中找到真正的记录并解析成一个完整的消息返回

  • 按照 Message Key 查询消息,IndexFile 索引文件为提供了通过 Message Key 查询消息的服务

    实现方式:通过 Broker 端的 QueryMessageProcessor 业务处理器来查询,读取消息的过程用 Topic 和 Key 找到 IndexFile 索引文件中的一条记录,根据其中的 CommitLog Offset 从 CommitLog 文件中读取消息的实体内容


索引机制

RocketMQ 的索引文件逻辑结构,类似 JDK 中 HashMap 的实现,具体结构如下:

IndexFile 文件的存储在 $HOME\store\index${fileName},文件名 fileName 是以创建时的时间戳命名,文件大小是固定的,等于 40+500W*4+2000W*20= 420000040 个字节大小。如果消息的 properties 中设置了 UNIQ_KEY 这个属性,就用 topic + “#” + UNIQ_KEY 作为 key 来做写入操作;如果消息设置了 KEYS 属性(多个 KEY 以空格分隔),也会用 topic + “#” + KEY 来做索引

整个 Index File 的结构如图,40 Byte 的 Header 用于保存一些总的统计信息,4*500W 的 Slot Table 并不保存真正的索引数据,而是保存每个槽位对应的单向链表的头指针,即一个 Index File 可以保存 2000W 个索引,20*2000W真正的索引数据

索引数据包含了 Key Hash/CommitLog Offset/Timestamp/NextIndex offset 这四个字段,一共 20 Byte

  • NextIndex offset 即前面读出来的 slotValue,如果有 hash 冲突,就可以用这个字段将所有冲突的索引用链表的方式串起来
  • Timestamp 记录的是消息 storeTimestamp 之间的差,并不是一个绝对的时间

参考文档:https://github.com/apache/rocketmq/blob/master/docs/cn/design.md


消息重试

消息重投

生产者在发送消息时,同步消息和异步消息失败会重投,oneway 没有任何保证。消息重投保证消息尽可能发送成功、不丢失,但当出现消息量大、网络抖动时,可能会造成消息重复;生产者主动重发、Consumer 负载变化也会导致重复消息。

如下方法可以设置消息重投策略:

  • retryTimesWhenSendFailed:同步发送失败重投次数,默认为 2,因此生产者会最多尝试发送 retryTimesWhenSendFailed + 1 次。不会选择上次失败的 Broker,尝试向其他 Broker 发送,最大程度保证消息不丢。超过重投次数抛出异常,由客户端保证消息不丢。当出现 RemotingException、MQClientException 和部分 MQBrokerException 时会重投
  • retryTimesWhenSendAsyncFailed:异步发送失败重试次数,异步重试不会选择其他 Broker,仅在同一个 Broker 上做重试,不保证消息不丢
  • retryAnotherBrokerWhenNotStoreOK:消息刷盘(主或备)超时或 slave 不可用(返回状态非 SEND_OK),是否尝试发送到其他 Broker,默认 false,十分重要消息可以开启

注意点:

  • 如果同步模式发送失败,则选择到下一个 Broker,如果异步模式发送失败,则只会在当前 Broker 进行重试
  • 发送消息超时时间默认 3000 毫秒,就不会再尝试重试

消息重试

Consumer 消费消息失败后,提供了一种重试机制,令消息再消费一次。Consumer 消费消息失败可以认为有以下几种情况:

  • 由于消息本身的原因,例如反序列化失败,消息数据本身无法处理等。这种错误通常需要跳过这条消息,再消费其它消息,而这条失败的消息即使立刻重试消费,99% 也不成功,所以需要提供一种定时重试机制,即过 10 秒后再重试
  • 由于依赖的下游应用服务不可用,例如 DB 连接不可用,外系统网络不可达等。这种情况即使跳过当前失败的消息,消费其他消息同样也会报错,这种情况建议应用 sleep 30s,再消费下一条消息,这样可以减轻 Broker 重试消息的压力

RocketMQ 会为每个消费组都设置一个 Topic 名称为 %RETRY%+consumerGroup 的重试队列(这个 Topic 的重试队列是针对消费组,而不是针对每个 Topic 设置的),用于暂时保存因为各种异常而导致 Consumer 端无法消费的消息

  • 顺序消息的重试,当消费者消费消息失败后,消息队列 RocketMQ 会自动不断进行消息重试(每次间隔时间为 1 秒),这时应用会出现消息消费被阻塞的情况。所以在使用顺序消息时,必须保证应用能够及时监控并处理消费失败的情况,避免阻塞现象的发生

  • 无序消息(普通、定时、延时、事务消息)的重试,可以通过设置返回状态达到消息重试的结果。无序消息的重试只针对集群消费方式生效,广播方式不提供失败重试特性,即消费失败后,失败消息不再重试,继续消费新的消息

无序消息情况下,因为异常恢复需要一些时间,会为重试队列设置多个重试级别,每个重试级别都有对应的重新投递延时,重试次数越多投递延时就越大。RocketMQ 对于重试消息的处理是先保存至 Topic 名称为 SCHEDULE_TOPIC_XXXX 的延迟队列中,后台定时任务按照对应的时间进行 Delay 后重新保存至 %RETRY%+consumerGroup 的重试队列中

消息队列 RocketMQ 默认允许每条消息最多重试 16 次,每次重试的间隔时间如下表示:

第几次重试 与上次重试的间隔时间 第几次重试 与上次重试的间隔时间
1 10 秒 9 7 分钟
2 30 秒 10 8 分钟
3 1 分钟 11 9 分钟
4 2 分钟 12 10 分钟
5 3 分钟 13 20 分钟
6 4 分钟 14 30 分钟
7 5 分钟 15 1 小时
8 6 分钟 16 2 小时

如果消息重试 16 次后仍然失败,消息将不再投递,如果严格按照上述重试时间间隔计算,某条消息在一直消费失败的前提下,将会在接下来的 4 小时 46 分钟之内进行 16 次重试,超过这个时间范围消息将不再重试投递

时间间隔不支持自定义配置,最大重试次数可通过自定义参数 MaxReconsumeTimes 取值进行配置,若配置超过 16 次,则超过的间隔时间均为 2 小时

说明:一条消息无论重试多少次,消息的 Message ID 是不会改变的


重试操作

集群消费方式下,消息消费失败后期望消息重试,需要在消息监听器接口的实现中明确进行配置(三种方式任选一种):

  • 返回 Action.ReconsumeLater (推荐)
  • 返回 null
  • 抛出异常
1
2
3
4
5
6
7
8
9
10
11
12
13
public class MessageListenerImpl implements MessageListener {
@Override
public Action consume(Message message, ConsumeContext context) {
// 处理消息
doConsumeMessage(message);
//方式1:返回 Action.ReconsumeLater,消息将重试
return Action.ReconsumeLater;
//方式2:返回 null,消息将重试
return null;
//方式3:直接抛出异常, 消息将重试
throw new RuntimeException("Consumer Message exceotion");
}
}

集群消费方式下,消息失败后期望消息不重试,需要捕获消费逻辑中可能抛出的异常,最终返回 Action.CommitMessage,此后这条消息将不会再重试

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MessageListenerImpl implements MessageListener {
@Override
public Action consume(Message message, ConsumeContext context) {
try {
doConsumeMessage(message);
} catch (Throwable e) {
// 捕获消费逻辑中的所有异常,并返回 Action.CommitMessage;
return Action.CommitMessage;
}
//消息处理正常,直接返回 Action.CommitMessage;
return Action.CommitMessage;
}
}

自定义消息最大重试次数,RocketMQ 允许 Consumer 启动的时候设置最大重试次数,重试时间间隔将按照如下策略:

  • 最大重试次数小于等于 16 次,则重试时间间隔同上表描述
  • 最大重试次数大于 16 次,超过 16 次的重试时间间隔均为每次 2 小时
1
2
3
4
Properties properties = new Properties();
// 配置对应 Group ID 的最大消息重试次数为 20 次
properties.put(PropertyKeyConst.MaxReconsumeTimes,"20");
Consumer consumer = ONSFactory.createConsumer(properties);

注意:

  • 消息最大重试次数的设置对相同 Group ID 下的所有 Consumer 实例有效。例如只对相同 Group ID 下两个 Consumer 实例中的其中一个设置了 MaxReconsumeTimes,那么该配置对两个 Consumer 实例均生效
  • 配置采用覆盖的方式生效,即最后启动的 Consumer 实例会覆盖之前的启动实例的配置

消费者收到消息后,可按照如下方式获取消息的重试次数:

1
2
3
4
5
6
7
8
public class MessageListenerImpl implements MessageListener {
@Override
public Action consume(Message message, ConsumeContext context) {
// 获取消息的重试次数
System.out.println(message.getReconsumeTimes());
return Action.CommitMessage;
}
}

死信队列

正常情况下无法被消费的消息称为死信消息(Dead-Letter Message),存储死信消息的特殊队列称为死信队列(Dead-Letter Queue)

当一条消息初次消费失败,消息队列 RocketMQ 会自动进行消息重试,达到最大重试次数后,若消费依然失败,则表明消费者在正常情况下无法正确地消费该消息,此时 RocketMQ 不会立刻将消息丢弃,而是将其发送到该消费者对应的死信队列中

死信消息具有以下特性:

  • 不会再被消费者正常消费
  • 有效期与正常消息相同,均为 3 天,3 天后会被自动删除,所以请在死信消息产生后的 3 天内及时处理

死信队列具有以下特性:

  • 一个死信队列对应一个 Group ID, 而不是对应单个消费者实例
  • 如果一个 Group ID 未产生死信消息,消息队列 RocketMQ 不会为其创建相应的死信队列
  • 一个死信队列包含了对应 Group ID 产生的所有死信消息,不论该消息属于哪个 Topic

一条消息进入死信队列,需要排查可疑因素并解决问题后,可以在消息队列 RocketMQ 控制台重新发送该消息,让消费者重新消费一次


幂等消费

消息队列 RocketMQ 消费者在接收到消息以后,需要根据业务上的唯一 Key 对消息做幂等处理

At least Once 机制保证消息不丢失,但是可能会造成消息重复,RocketMQ 中无法避免消息重复(Exactly-Once),在互联网应用中,尤其在网络不稳定的情况下,几种情况:

  • 发送时消息重复:当一条消息已被成功发送到服务端并完成持久化,此时出现了网络闪断或客户端宕机,导致服务端对客户端应答失败。此时生产者意识到消息发送失败并尝试再次发送消息,消费者后续会收到两条内容相同并且 Message ID 也相同的消息

  • 投递时消息重复:消息消费的场景下,消息已投递到消费者并完成业务处理,当客户端给服务端反馈应答的时候网络闪断。为了保证消息至少被消费一次,消息队列 RocketMQ 的服务端将在网络恢复后再次尝试投递之前已被处理过的消息,消费者后续会收到两条内容相同并且 Message ID 也相同的消息

  • 负载均衡时消息重复:当消息队列 RocketMQ 的 Broker 或客户端重启、扩容或缩容时,会触发 Rebalance,此时消费者可能会收到重复消息

处理方式:

  • 因为 Message ID 有可能出现冲突(重复)的情况,所以真正安全的幂等处理,不建议以 Message ID 作为处理依据,最好的方式是以业务唯一标识作为幂等处理的关键依据,而业务的唯一标识可以通过消息 Key 进行设置:

    1
    2
    3
    Message message = new Message();
    message.setKey("ORDERID_100");
    SendResult sendResult = producer.send(message);
  • 订阅方收到消息时可以根据消息的 Key 进行幂等处理:

    1
    2
    3
    4
    5
    6
    consumer.subscribe("ons_test", "*", new MessageListener() {
    public Action consume(Message message, ConsumeContext context) {
    String key = message.getKey()
    // 根据业务唯一标识的 key 做幂等处理
    }
    });

流量控制

生产者流控,因为 Broker 处理能力达到瓶颈;消费者流控,因为消费能力达到瓶颈

生产者流控:

  • CommitLog 文件被锁时间超过 osPageCacheBusyTimeOutMills 时,参数默认为 1000ms,返回流控
  • 如果开启 transientStorePoolEnable == true,且 Broker 为异步刷盘的主机,且 transientStorePool 中资源不足,拒绝当前 send 请求,返回流控
  • Broker 每隔 10ms 检查 send 请求队列头部请求的等待时间,如果超过 waitTimeMillsInSendQueue,默认 200ms,拒绝当前 send 请求,返回流控。
  • Broker 通过拒绝 send 请求方式实现流量控制

注意:生产者流控,不会尝试消息重投

消费者流控:

  • 消费者本地缓存消息数超过 pullThresholdForQueue 时,默认 1000
  • 消费者本地缓存消息大小超过 pullThresholdSizeForQueue 时,默认 100MB
  • 消费者本地缓存消息跨度超过 consumeConcurrentlyMaxSpan 时,默认 2000

消费者流控的结果是降低拉取频率


原理解析

Namesrv

服务启动

启动方法

NamesrvStartup 类中有 Namesrv 服务的启动方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static void main(String[] args) {
// 如果启动时 使用 -c -p 设置参数了,这些参数存储在 args 中
main0(args);
}

public static NamesrvController main0(String[] args) {
try {
// 创建 namesrv 控制器,用来初始化 namesrv 启动 namesrv 关闭 namesrv
NamesrvController controller = createNamesrvController(args);
// 启动 controller
start(controller);
return controller;
} catch (Throwable e) {
// 出现异常,停止系统
System.exit(-1);
}
return null;
}

NamesrvStartup#createNamesrvController:读取配置信息,初始化 Namesrv 控制器

  • ServerUtil.parseCmdLine("mqnamesrv", args, buildCommandlineOptions(options),..):解析启动时的参数信息

  • namesrvConfig = new NamesrvConfig():创建 Namesrv 配置对象

    • private String rocketmqHome:获取 ROCKETMQ_HOME 值
    • private boolean orderMessageEnable = false顺序消息功能是否开启
  • nettyServerConfig = new NettyServerConfig():Netty 的服务器配置对象

  • nettyServerConfig.setListenPort(9876):Namesrv 服务器的监听端口设置为 9876

  • if (commandLine.hasOption('c')):读取命令行 -c 的参数值

    in = new BufferedInputStream(new FileInputStream(file)):读取指定目录的配置文件

    properties.load(in):将配置文件信息加载到 properties 对象,相关属性会复写到 Namesrv 配置和 Netty 配置对象

    namesrvConfig.setConfigStorePath(file):将配置文件的路径保存到配置保存字段

  • if (null == namesrvConfig.getRocketmqHome()):检查 ROCKETMQ_HOME 配置是否是空,是空就报错

  • lc = (LoggerContext) LoggerFactory.getILoggerFactory():创建日志对象

  • controller = new NamesrvController(namesrvConfig, nettyServerConfig)创建 Namesrv 控制器

NamesrvStartup#start:启动 Namesrv 控制器

  • boolean initResult = controller.initialize():初始化方法

  • Runtime.getRuntime().addShutdownHook(new ShutdownHookThread()):JVM HOOK 平滑关闭的逻辑, 当 JVM 被关闭时,主动调用 controller.shutdown() 方法,让服务器平滑关机

  • controller.start():启动服务器

源码解析参考视频:https://space.bilibili.com/457326371


控制器类

NamesrvController 用来初始化和启动 Namesrv 服务器

  • 成员变量:

    1
    2
    3
    4
    private final ScheduledExecutorService scheduledExecutorService;	// 调度线程池,用来执行定时任务
    private final RouteInfoManager routeInfoManager; // 管理【路由信息】的对象
    private RemotingServer remotingServer; // 【网络层】封装对象
    private BrokerHousekeepingService brokerHousekeepingService; // 用于监听 channel 状态

    private ExecutorService remotingExecutor:业务线程池,netty 线程解析报文成 RemotingCommand 对象,然后将该对象交给业务线程池再继续处理

  • 初始化:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    public boolean initialize() {
    // 加载本地kv配置(我还不明白 kv 配置是啥)
    this.kvConfigManager.load();
    // 创建网络服务器对象,【将 netty 的配置和监听器传入】
    // 监听器监听 channel 状态的改变,会向事件队列发起事件,最后交由 service 处理
    this.remotingServer = new NettyRemotingServer(this.nettyServerConfig, this.brokerHousekeepingService);
    // 【创建业务线程池,默认线程数 8】
    this.remotingExecutor = Executors.newFixedThreadPool(nettyServerConfig.getServerWorkerThreads().);

    // 注册协议处理器(缺省协议处理器),【处理器是 DefaultRequestProcessor】,线程使用的是刚创建的业务的线程池
    this.registerProcessor();

    // 定时任务1:每 10 秒钟检查 broker 存活状态,将 IDLE 状态的 broker 移除【扫描机制,心跳检测】
    this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
    @Override
    public void run() {
    // 扫描 brokerLiveTable 表,将两小时没有活动的 broker 关闭,
    // 通过 next.getKey() 获取 broker 的地址,然后【关闭服务器与broker物理节点的 channel】
    NamesrvController.this.routeInfoManager.scanNotActiveBroker();
    }
    }, 5, 10, TimeUnit.SECONDS);

    // 定时任务2:每 10 分钟打印一遍 kv 配置。
    this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
    @Override
    public void run() {
    NamesrvController.this.kvConfigManager.printAllPeriodically();
    }
    }, 1, 10, TimeUnit.MINUTES);

    return true;
    }
  • 启动方法:

    1
    2
    3
    4
    5
    6
    7
    8
    public void start() throws Exception {
    // 服务器网络层启动。
    this.remotingServer.start();

    if (this.fileWatchService != null) {
    this.fileWatchService.start();
    }
    }

网络通信

通信原理

RocketMQ 的 RPC 通信采用 Netty 组件作为底层通信库,同样也遵循了 Reactor 多线程模型,NettyRemotingServer 类负责框架的通信服务,同时又在这之上做了一些扩展和优化

RocketMQ 基于 NettyRemotingServer 的 Reactor 多线程模型:

  • 一个 Reactor 主线程(eventLoopGroupBoss)负责监听 TCP 网络连接请求,建立好连接创建 SocketChannel(RocketMQ 会自动根据 OS 的类型选择 NIO 和 Epoll,也可以通过参数配置),并注册到 Selector 上,然后监听真正的网络数据

  • 拿到网络数据交给 Worker 线程池(eventLoopGroupSelector,默认设置为 3),在真正执行业务逻辑之前需要进行 SSL 验证、编解码、空闲检查、网络连接管理,这些工作交给 defaultEventExecutorGroup(默认设置为 8)去做

  • 处理业务操作放在业务线程池中执行,根据 RomotingCommand 的业务请求码 code 去 processorTable 这个本地缓存变量中找到对应的 processor,封装成 task 任务提交给对应的 processor 处理线程池来执行(sendMessageExecutor,以发送消息为例)

  • 从入口到业务逻辑的几个步骤中线程池一直再增加,这跟每一步逻辑复杂性相关,越复杂,需要的并发通道越宽

线程数 线程名 线程具体说明
1 NettyBoss_%d Reactor 主线程
N NettyServerEPOLLSelector_%d_%d Reactor 线程池
M1 NettyServerCodecThread_%d Worker 线程池
M2 RemotingExecutorThread_%d 业务 processor 处理线程池

RocketMQ 的异步通信流程:

==todo:后期对 Netty 有了更深的认知后会进行扩充,现在暂时 copy 官方文档==

官方文档:https://github.com/apache/rocketmq/blob/master/docs/cn/design.md#2-%E9%80%9A%E4%BF%A1%E6%9C%BA%E5%88%B6


成员属性

NettyRemotingServer 类成员变量:

  • 服务器相关属性:

    1
    2
    3
    4
    5
    private final ServerBootstrap serverBootstrap;				// netty 服务端启动对象
    private final EventLoopGroup eventLoopGroupSelector; // netty worker 组线程池,【默认 3 个线程】
    private final EventLoopGroup eventLoopGroupBoss; // netty boss 组线程池,【一般是 1 个线程】
    private final NettyServerConfig nettyServerConfig; // netty 服务端网络配置
    private int port = 0; // 服务器绑定的端口
  • 公共线程池:注册处理器时如果未指定线程池,则业务处理使用公共线程池,线程数量默认是 4

    1
    private final ExecutorService publicExecutor;
  • 事件监听器:Nameserver 使用 BrokerHouseKeepingService,Broker 使用 ClientHouseKeepingService

    1
    private final ChannelEventListener channelEventListener;
  • 事件处理线程池:默认是 8

    1
    private DefaultEventExecutorGroup defaultEventExecutorGroup;
  • 定时器:执行循环任务,并且将定时器线程设置为守护线程

    1
    private final Timer timer = new Timer("ServerHouseKeepingService", true);
  • 处理器:多个 Channel 共享的处理器 Handler,多个通道使用同一个对象

  • Netty 配置对象:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    public class NettyServerConfig implements Cloneable {
    // 服务端启动时监听的端口号
    private int listenPort = 8888;
    // 【业务线程池】 线程数量
    private int serverWorkerThreads = 8;
    // 根据该值创建 remotingServer 内部的一个 publicExecutor
    private int serverCallbackExecutorThreads = 0;
    // netty 【worker】线程数
    private int serverSelectorThreads = 3;
    // 【单向访问】时的并发限制
    private int serverOnewaySemaphoreValue = 256;
    // 【异步访问】时的并发限制
    private int serverAsyncSemaphoreValue = 64;
    // channel 最大的空闲存活时间 默认是 2min
    private int serverChannelMaxIdleTimeSeconds = 120;
    // 发送缓冲区大小 65535
    private int serverSocketSndBufSize = NettySystemConfig.socketSndbufSize;
    // 接收缓冲区大小 65535
    private int serverSocketRcvBufSize = NettySystemConfig.socketRcvbufSize;
    // 是否启用 netty 内存池 默认开启
    private boolean serverPooledByteBufAllocatorEnable = true;

    // 默认 linux 会启用 【epoll】
    private boolean useEpollNativeSelector = false;
    }

构造方法:

  • 无监听器构造:

    1
    2
    3
    public NettyRemotingServer(final NettyServerConfig nettyServerConfig) {
    this(nettyServerConfig, null);
    }
  • 有参构造方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    public NettyRemotingServer(final NettyServerConfig nettyServerConfig,
    final ChannelEventListener channelEventListener) {
    // 服务器对客户端主动发起请求时并发限制。【单向请求和异步请求】的并发限制
    super(nettyServerConfig.getServerOnewaySemaphoreValue(), nettyServerConfig.getServerAsyncSemaphoreValue());
    // Netty 的启动器,负责组装 netty 组件
    this.serverBootstrap = new ServerBootstrap();
    // 成员变量的赋值
    this.nettyServerConfig = nettyServerConfig;
    this.channelEventListener = channelEventListener;

    // 公共线程池的线程数量,默认给的0,这里最终修改为4.
    int publicThreadNums = nettyServerConfig.getServerCallbackExecutorThreads();
    if (publicThreadNums <= 0) {
    publicThreadNums = 4;
    }
    // 创建公共线程池,指定线程工厂,设置线程名称前缀:NettyServerPublicExecutor_[数字]
    this.publicExecutor = Executors.newFixedThreadPool(publicThreadNums, new ThreadFactory(){.});

    // 创建两个 netty 的线程组,一个是boss组,一个是worker组,【linux 系统默认启用 epoll】
    if (useEpoll()) {...} else {...}
    // SSL 相关
    loadSslContext();
    }

启动方法

核心方法的解析:

  • start():启动方法,创建 BootStrap,并添加 NettyServerHandler 处理器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    public void start() {
    // Channel Pipeline 内的 handler 使用的线程资源,【线程分配给 handler 处理事件】
    this.defaultEventExecutorGroup = new DefaultEventExecutorGroup(...);

    // 创建通用共享的处理器 handler,【非常重要的 NettyServerHandler】
    prepareSharableHandlers();

    ServerBootstrap childHandler =
    // 配置工作组 boss(数量1) 和 worker(数量3) 组
    this.serverBootstrap.group(this.eventLoopGroupBoss, this.eventLoopGroupSelector)
    // 设置服务端 ServerSocketChannel 类型, Linux 用 epoll
    .channel(useEpoll() ? EpollServerSocketChannel.class : NioServerSocketChannel.class)
    // 设置服务端 channel 选项
    .option(ChannelOption.SO_BACKLOG, 1024)
    // 客户端 channel 选项
    .childOption(ChannelOption.TCP_NODELAY, true)
    // 设置服务器端口
    .localAddress(new InetSocketAddress(this.nettyServerConfig.getListenPort()))
    // 向 channel pipeline 添加了很多 handler,【包括 NettyServerHandler】
    .childHandler(new ChannelInitializer<SocketChannel>() {});

    // 客户端开启 内存池,使用的内存池是 PooledByteBufAllocator.DEFAULT
    if (nettyServerConfig.isServerPooledByteBufAllocatorEnable()) {
    childHandler.childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT);
    }

    try {
    // 同步等待建立连接,并绑定端口。
    ChannelFuture sync = this.serverBootstrap.bind().sync();
    InetSocketAddress addr = (InetSocketAddress) sync.channel().localAddress();
    // 将服务器成功绑定的端口号赋值给字段 port。
    this.port = addr.getPort();
    } catch (InterruptedException e1) {}

    // housekeepingService 不为空,则创建【网络异常事件处理器】
    if (this.channelEventListener != null) {
    // 线程一直轮询 nettyEvent 状态,根据 CONNECT,CLOSE,IDLE,EXCEPTION 四种事件类型
    // CONNECT 不做操作,其余都是回调 onChannelDestroy 【关闭服务器与 Broker 物理节点的 Channel】
    this.nettyEventExecutor.start();
    }

    // 提交定时任务,每一秒 执行一次。扫描 responseTable 表,将过期的数据移除
    this.timer.scheduleAtFixedRate(new TimerTask() {
    @Override
    public void run() {
    NettyRemotingServer.this.scanResponseTable();
    }
    }, 1000 * 3, 1000);
    }
  • registerProcessor():注册业务处理器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public void registerProcessor(int requestCode, NettyRequestProcessor processor, ExecutorService executor) {
    ExecutorService executorThis = executor;
    if (null == executor) {
    // 未指定线程池资源,将公共线程池赋值
    executorThis = this.publicExecutor;
    }
    // pair 对象,第一个参数代表的是处理器, 第二个参数是线程池,默认是公共的线程池
    Pair<NettyRequestProcessor, ExecutorService> pair = new Pair<NettyRequestProcessor, ExecutorService>(processor, executorThis);

    // key 是请求码,value 是 Pair 对象
    this.processorTable.put(requestCode, pair);
    }
  • getProcessorPair():根据请求码获取对应的处理器和线程池资源

    1
    2
    3
    public Pair<NettyRequestProcessor, ExecutorService> getProcessorPair(int requestCode) {
    return processorTable.get(requestCode);
    }

请求方法

在 RocketMQ 消息队列中支持通信的方式主要有同步(sync)、异步(async)、单向(oneway)三种,其中单向通信模式相对简单,一般用在发送心跳包场景下,无需关注其 Response

服务器主动向客户端发起请求时,使用三种方法

  • invokeSync(): 同步调用,服务器需要阻塞等待调用的返回结果

    • int opaque = request.getOpaque():获取请求 ID(与请求码不同)
    • responseFuture = new ResponseFuture(...)创建响应对象,没有回调函数和 Once
    • this.responseTable.put(opaque, responseFuture)加入到响应映射表中,key 为请求 ID
    • SocketAddress addr = channel.remoteAddress():获取客户端的地址信息
    • channel.writeAndFlush(request).addListener(...):将业务 Command 信息写入通道,业务线程将数据交给 Netty ,Netty 的 IO 线程接管写刷数据的操作,监听器由 IO 线程在写刷后回调
      • if (f.isSuccess()):写入成功会将响应对象设置为成功状态直接 return,写入失败设置为失败状态
      • responseTable.remove(opaque):将当前请求的 responseFuture 从映射表移除
      • responseFuture.setCause(f.cause()):设置错误的信息
      • responseFuture.putResponse(null):响应 Command 设置为 null
    • responseCommand = responseFuture.waitResponse(timeoutMillis):当前线程设置超时时间挂起,同步等待响应
    • if (null == responseCommand):超时或者出现异常,直接报错
    • return responseCommand:返回响应 Command 信息
  • invokeAsync():异步调用,有回调对象,无返回值

    • boolean acquired = this.semaphoreAsync.tryAcquire(timeoutMillis, TimeUnit.MILLISECONDS):获取信号量的许可证,信号量用来限制异步请求的数量
    • if (acquired):许可证获取失败说明并发较高,会抛出异常
    • once = new SemaphoreReleaseOnlyOnce(this.semaphoreAsync):Once 对象封装了释放信号量的操作
    • costTime = System.currentTimeMillis() - beginStartTime:计算一下耗费的时间,超时不再发起请求
    • responseFuture = new ResponseFuture()创建响应对象,包装了回调函数和 Once 对象
    • this.responseTable.put(opaque, responseFuture):加入到响应映射表中,key 为请求 ID
    • channel.writeAndFlush(request).addListener(...):写刷数据
      • if (f.isSuccess()):写刷成功,设置 responseFuture 发生状态为 true
      • requestFail(opaque):写入失败,使用 publicExecutor 公共线程池异步执行回调对象的函数
      • responseFuture.release():出现异常会释放信号量
  • invokeOneway():单向调用,不关注响应结果

    • request.markOnewayRPC():设置单向标记,对端检查标记可知该请是单向请求
    • boolean acquired = this.semaphoreOneway.tryAcquire(timeoutMillis, TimeUnit.MILLISECONDS):获取信号量的许可证,信号量用来限制单向请求的数量

处理器类

协议设计

在 Client 和 Server 之间完成一次消息发送时,需要对发送的消息进行一个协议约定,所以自定义 RocketMQ 的消息协议。在 RocketMQ 中,为了高效地在网络中传输消息和对收到的消息读取,就需要对消息进行编解码,RemotingCommand 这个类在消息传输过程中对所有数据内容的封装,不但包含了所有的数据结构,还包含了编码解码操作

Header字段 类型 Request 说明 Response 说明
code int 请求操作码,应答方根据不同的请求码进行不同的处理 应答响应码,0 表示成功,非 0 则表示各种错误
language LanguageCode 请求方实现的语言 应答方实现的语言
version int 请求方程序的版本 应答方程序的版本
opaque int 相当于 requestId,在同一个连接上的不同请求标识码,与响应消息中的相对应 应答不做修改直接返回
flag int 区分是普通 RPC 还是 onewayRPC 的标志 区分是普通 RPC 还是 onewayRPC的标志
remark String 传输自定义文本信息 传输自定义文本信息
extFields HashMap<String, String> 请求自定义扩展信息 响应自定义扩展信息

传输内容主要可以分为以下四部分:

  • 消息长度:总长度,四个字节存储,占用一个 int 类型

  • 序列化类型&消息头长度:同样占用一个 int 类型,第一个字节表示序列化类型,后面三个字节表示消息头长度

  • 消息头数据:经过序列化后的消息头数据

  • 消息主体数据:消息主体的二进制字节数据内容

官方文档:https://github.com/apache/rocketmq/blob/master/docs/cn/design.md


处理方法

NettyServerHandler 类用来处理 Channel 上的事件,在 NettyRemotingServer 启动时注册到 Netty 中,可以处理 RemotingCommand 相关的数据,针对某一种类型的请求处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class NettyServerHandler extends SimpleChannelInboundHandler<RemotingCommand> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, RemotingCommand msg) throws Exception {
// 服务器处理接受到的请求信息
processMessageReceived(ctx, msg);
}
}
public void processMessageReceived(ChannelHandlerContext ctx, RemotingCommand msg) throws Exception {
final RemotingCommand cmd = msg;
if (cmd != null) {
// 根据请求的类型进行处理
switch (cmd.getType()) {
case REQUEST_COMMAND:// 客户端发起的请求,走这里
processRequestCommand(ctx, cmd);
break;
case RESPONSE_COMMAND:// 客户端响应的数据,走这里【当前类本身是服务器类也是客户端类】
processResponseCommand(ctx, cmd);
break;
default:
break;
}
}
}

NettyRemotingAbstract#processRequestCommand:处理请求的数据

  • matched = this.processorTable.get(cmd.getCode()):根据业务请求码获取 Pair 对象,包含处理器和线程池资源

  • pair = null == matched ? this.defaultRequestProcessor : matched:未找到处理器则使用缺省处理器

  • int opaque = cmd.getOpaque():获取请求 ID

  • Runnable run = new Runnable():创建任务对象,任务在提交到线程池后开始执行

    • doBeforeRpcHooks():RPC HOOK 前置处理

    • callback = new RemotingResponseCallback()封装响应客户端的逻辑

      • doAfterRpcHooks():RPC HOOK 后置处理
      • if (!cmd.isOnewayRPC()):条件成立说明不是单向请求,需要结果
      • response.setOpaque(opaque):将请求 ID 设置到 response
      • response.markResponseType()设置当前请求是响应
      • ctx.writeAndFlush(response)将响应数据交给 Netty IO 线程,完成数据写和刷
    • if (pair.getObject1() instanceof AsyncNettyRequestProcessor):Nameserver 默认使用 DefaultRequestProcessor 处理器,是一个 AsyncNettyRequestProcessor 子类

    • processor = (AsyncNettyRequestProcessor)pair.getObject1():获取处理器

    • processor.asyncProcessRequest(ctx, cmd, callback):异步调用,首先 processRequest,然后 callback 响应客户端

      DefaultRequestProcessor.processRequest根据业务码处理请求,执行对应的操作

      ClientRemotingProcessor.processRequest:处理事务回查消息,或者回执消息,需要消费者回执一条消息给生产者

  • requestTask = new RequestTask(run, ctx.channel(), cmd):将任务对象、通道、请求封装成 RequestTask 对象

  • pair.getObject2().submit(requestTask)获取处理器对应的线程池,将 task 提交,从 IO 线程切换到业务线程

NettyRemotingAbstract#processResponseCommand:处理响应的数据

  • int opaque = cmd.getOpaque():获取请求 ID
  • responseFuture = responseTable.get(opaque)从响应映射表中获取对应的对象
  • responseFuture.setResponseCommand(cmd):设置响应的 Command 对象
  • responseTable.remove(opaque):从映射表中移除对象,代表处理完成
  • if (responseFuture.getInvokeCallback() != null):包含回调对象,异步执行回调对象
  • responseFuture.putResponse(cmd):不包含回调对象,同步调用时,唤醒等待的业务线程

流程:客户端 invokeSync → 服务器的 processRequestCommand → 客户端的 processResponseCommand → 结束


路由信息

信息管理

RouteInfoManager 类负责管理路由信息,NamesrvController 的构造方法中创建该类的实例对象,管理服务端的路由数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class RouteInfoManager {
// Broker 两个小时不活跃,视为离线,被定时任务删除
private final static long BROKER_CHANNEL_EXPIRED_TIME = 1000 * 60 * 2;
// 读写锁,保证线程安全
private final ReadWriteLock lock = new ReentrantReadWriteLock();
// 主题队列数据,一个主题对应多个队列
private final HashMap<String/* topic */, List<QueueData>> topicQueueTable;
// Broker 数据列表
private final HashMap<String/* brokerName */, BrokerData> brokerAddrTable;
// 集群
private final HashMap<String/* clusterName */, Set<String/* brokerName */>> clusterAddrTable;
// Broker 存活信息
private final HashMap<String/* brokerAddr */, BrokerLiveInfo> brokerLiveTable;
// 服务过滤
private final HashMap<String/* brokerAddr */, List<String>/* Filter Server */> filterServerTable;
}

路由注册

DefaultRequestProcessor REGISTER_BROKER 方法解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public RemotingCommand registerBroker(ChannelHandlerContext ctx, RemotingCommand request) {
// 创建响应请求的对象,设置为响应类型,【先设置响应的状态码时系统错误码】
// 反射创建 RegisterBrokerResponseHeader 对象设置到 response.customHeader 属性中
final RemotingCommand response = RemotingCommand.createResponseCommand(RegisterBrokerResponseHeader.class);

// 获取出反射创建的 RegisterBrokerResponseHeader 用户自定义header对象。
final RegisterBrokerResponseHeader responseHeader = (RegisterBrokerResponseHeader) response.readCustomHeader();

// 反射创建 RegisterBrokerRequestHeader 对象,并且将 request.extFields 中的数据写入到该对象中
final RegisterBrokerRequestHeader requestHeader = request.decodeCommandCustomHeader(RegisterBrokerRequestHeader.class);

// CRC 校验,计算请求中的 CRC 值和请求头中包含的是否一致
if (!checksum(ctx, request, requestHeader)) {
response.setCode(ResponseCode.SYSTEM_ERROR);
response.setRemark("crc32 not match");
return response;
}

TopicConfigSerializeWrapper topicConfigWrapper;
if (request.getBody() != null) {
// 【解析请求体 body】,解码出来的数据就是当前机器的主题信息
topicConfigWrapper = TopicConfigSerializeWrapper.decode(request.getBody(), TopicConfigSerializeWrapper.class);
} else {
topicConfigWrapper = new TopicConfigSerializeWrapper();
topicConfigWrapper.getDataVersion().setCounter(new AtomicLong(0));
topicConfigWrapper.getDataVersion().setTimestamp(0);
}

// 注册方法
// 参数1 集群、参数2:节点ip地址、参数3:brokerName、参数4:brokerId 注意brokerId=0的节点为主节点
// 参数5:ha节点ip地址、参数6当前节点主题信息、参数7:过滤服务器列表、参数8:当前服务器和客户端通信的channel
RegisterBrokerResult result = this.namesrvController.getRouteInfoManager().registerBroker(..);

// 将结果信息 写到 responseHeader 中
responseHeader.setHaServerAddr(result.getHaServerAddr());
responseHeader.setMasterAddr(result.getMasterAddr());
// 获取 kv配置,写入 response body 中,【kv 配置是顺序消息相关的】
byte[] jsonValue = this.namesrvController.getKvConfigManager().getKVListByNamespace(NamesrvUtil.NAMESPACE_ORDER_TOPIC);
response.setBody(jsonValue);

// code 设置为 SUCCESS
response.setCode(ResponseCode.SUCCESS);
response.setRemark(null);
// 返回 response ,【返回的 response 由 callback 对象处理】
return response;
}

RouteInfoManager#registerBroker:注册 Broker 的信息

  • RegisterBrokerResult result = new RegisterBrokerResult():返回结果的封装对象

  • this.lock.writeLock().lockInterruptibly():加写锁后同步执行

  • brokerNames = this.clusterAddrTable.get(clusterName):获取当前集群上的 Broker 名称列表,是空就新建列表

  • brokerNames.add(brokerName):将当前 Broker 名字加入到集群列表

  • brokerData = this.brokerAddrTable.get(brokerName):获取当前 Broker 的 brokerData,是空就新建放入映射表

  • brokerAddrsMap = brokerData.getBrokerAddrs():获取当前 Broker 的物理节点 map 表,进行遍历,如果物理节点角色发生变化(slave → master),先将旧数据从物理节点 map 中移除,然后重写放入,保证节点的唯一性

  • if (null != topicConfigWrapper && MixAll.MASTER_ID == brokerId):Broker 上的 Topic 不为 null,并且当前物理节点是 Broker 上的 master 节点

    tcTable = topicConfigWrapper.getTopicConfigTable():获取当前 Broker 信息中的主题映射表

    if (tcTable != null):映射表不空就加入或者更新到 Namesrv 内

  • prevBrokerLiveInfo = this.brokerLiveTable.put(brokerAddr):添加当前节点的 BrokerLiveInfo ,返回上一次心跳时当前 Broker 节点的存活对象数据。NamesrvController 中的定时任务会扫描映射表 brokerLiveTable

    1
    2
    BrokerLiveInfo prevBrokerLiveInfo = this.brokerLiveTable.put(brokerAddr, new BrokerLiveInfo(
    System.currentTimeMillis(),topicConfigWrapper.getDataVersion(), channel,haServerAddr));
  • if (MixAll.MASTER_ID != brokerId):当前 Broker 不是 master 节点,获取主节点的信息设置到结果对象

  • this.lock.writeLock().unlock():释放写锁


Broker

MappedFile

成员属性

MappedFile 类是最基础的存储类,继承自 ReferenceResource 类,用来保证线程安全

MappedFile 类成员变量:

  • 内存相关:

    1
    2
    3
    public static final int OS_PAGE_SIZE = 1024 * 4;// 内存页大小:默认是 4k
    private AtomicLong TOTAL_MAPPED_VIRTUAL_MEMORY; // 当前进程下所有的 mappedFile 占用的总虚拟内存大小
    private AtomicInteger TOTAL_MAPPED_FILES; // 当前进程下所有的 mappedFile 个数
  • 数据位点:

    1
    2
    3
    4
    protected final AtomicInteger wrotePosition;	// 当前 mappedFile 的数据写入点
    protected final AtomicInteger committedPosition;// 当前 mappedFile 的数据提交点
    private final AtomicInteger flushedPosition; // 数据落盘位点,在这之前的数据是持久化的安全数据
    // flushedPosition-wrotePosition 之间的数据属于脏页
  • 文件相关:CL 是 CommitLog,CQ 是 ConsumeQueue

    1
    2
    3
    private String fileName;	// 文件名称,CL和CQ文件名是【第一条消息的物理偏移量】,索引文件是【年月日时分秒】
    private long fileFromOffset;// 文件名转long,代表该对象的【起始偏移量】
    private File file; // 文件对象

    MF 中以物理偏移量作为文件名,可以更好的寻址和进行判断

  • 内存映射:

    1
    2
    protected FileChannel fileChannel;			// 文件通道
    private MappedByteBuffer mappedByteBuffer; // 内存映射缓冲区,访问虚拟内存

ReferenceResource 类成员变量:

  • 引用数量:当 refCount <= 0 时,表示该资源可以释放了,没有任何其他程序依赖它了,用原子类保证线程安全

    1
    protected final AtomicLong refCount = new AtomicLong(1);	// 初始值为 1
  • 存活状态:表示资源的存活状态

    1
    protected volatile boolean available = true;
  • 是否清理:默认值 false,当执行完子类对象的 cleanup() 清理方法后,该值置为 true ,表示资源已经全部释放

    1
    protected volatile boolean cleanupOver = false;
  • 第一次关闭资源的时间:用来记录超时时间

    1
    private volatile long firstShutdownTimestamp = 0;

成员方法

MappedFile 类核心方法:

  • appendMessage():提供上层向内存映射中追加消息的方法,消息如何追加由 AppendMessageCallback 控制

    1
    2
    // 参数一:消息     参数二:追加消息回调
    public AppendMessageResult appendMessage(MessageExtBrokerInner msg, AppendMessageCallback cb)
    1
    2
    // 将字节数组写入到文件通道
    public boolean appendMessage(final byte[] data)
  • flush():刷盘接口,参数 flushLeastPages 代表刷盘的最小页数 ,等于 0 时属于强制刷盘;> 0 时需要脏页(计算方法在数据位点)达到该值才进行物理刷盘;文件写满时强制刷盘

    1
    public int flush(final int flushLeastPages)
  • selectMappedBuffer():该方法以 pos 为开始位点 ,到有效数据为止,创建一个切片 ByteBuffer 作为数据副本,供业务访问数据

    1
    public SelectMappedBufferResult selectMappedBuffer(int pos)
  • destroy():销毁映射文件对象,并删除关联的系统文件,参数是强制关闭资源的时间

    1
    public boolean destroy(final long intervalForcibly)
  • cleanup():释放堆外内存,更新总虚拟内存和总内存映射文件数

    1
    public boolean cleanup(final long currentRef)
  • warmMappedFile():内存预热,当要新建的 MappedFile 对象大于 1g 时执行该方法。mappedByteBuffer 已经通过mmap映射,此时操作系统中只是记录了该文件和该 Buffer 的映射关系,而并没有映射到物理内存中,对该 MappedFile 的每个 Page Cache 进行写入一个字节分配内存,将映射文件全部加载到内存

    1
    public void warmMappedFile(FlushDiskType type, int pages)
  • mlock():锁住指定的内存区域避免被操作系统调到 swap 空间,减少了缺页异常的产生

    1
    public void mlock()

    swap space 是磁盘上的一块区域,可以是一个分区或者一个文件或者是组合。当系统物理内存不足时,Linux 会将内存中不常访问的数据保存到 swap 区域上,这样系统就可以有更多的物理内存为各个进程服务,而当系统需要访问 swap 上存储的内容时,需要通过缺页中断将 swap 上的数据加载到内存中

ReferenceResource 类核心方法:

  • hold():增加引用记数 refCount,方法加锁

    1
    public synchronized boolean hold()
  • shutdown():关闭资源,参数代表强制关闭资源的时间间隔

    1
    2
    // 系统当前时间 - firstShutdownTimestamp 时间  > intervalForcibly 进行【强制关闭】
    public void shutdown(final long intervalForcibly)
  • release():引用计数减 1,当 refCount 为 0 时,调用子类的 cleanup 方法

    1
    public void release()

MapQueue

成员属性

MappedFileQueue 用来管理 MappedFile 文件

成员变量:

  • 管理目录:CommitLog 是 ../store/commitlog, ConsumeQueue 是 ../store/xxx_topic/0

    1
    private final String storePath;
  • 文件属性:

    1
    2
    private final int mappedFileSize;	// 目录下每个文件大小,CL文件默认 1g,CQ文件 默认 600w字节
    private final CopyOnWriteArrayList<MappedFile> mappedFiles; //目录下的每个 mappedFile 都加入该集合
  • 数据位点:

    1
    2
    private long flushedWhere = 0;		// 目录的刷盘位点,值为 mf.fileName + mf.wrotePosition
    private long committedWhere = 0; // 目录的提交位点
  • 消息存储:

    1
    private volatile long storeTimestamp = 0;	// 当前目录下最后一条 msg 的存储时间
  • 创建服务:新建 MappedFile 实例,继承自 ServiceThread 是一个任务对象,run 方法用来创建实例

    1
    private final AllocateMappedFileService allocateMappedFileService;

成员方法

核心方法:

  • load():Broker 启动时,加载本地磁盘数据,该方法读取 storePath 目录下的文件,创建 MappedFile 对象放入集合内

    1
    public boolean load()
  • getLastMappedFile():获取当前正在顺序写入的 MappedFile 对象,如果最后一个 MappedFile 写满了,或者不存在 MappedFile 对象,则创建新的 MappedFile

    1
    2
    // 参数一:文件起始偏移量;参数二:当list为空时,是否新建 MappedFile
    public MappedFile getLastMappedFile(final long startOffset, boolean needCreate)
  • flush():根据 flushedWhere 属性查找合适的 MappedFile,调用该 MappedFile 的落盘方法,并更新全局的 flushedWhere

    1
    2
    //参数:0 表示强制刷新, > 0 脏页数据必须达到 flushLeastPages 才刷新
    public boolean flush(final int flushLeastPages)
  • findMappedFileByOffset():根据偏移量查询对象

    1
    public MappedFile findMappedFileByOffset(final long offset, final boolean returnFirstOnNotFound)
  • deleteExpiredFileByTime():CL 删除过期文件,根据文件的保留时长决定是否删除

    1
    2
    // 参数一:过期时间; 参数二:删除两个文件之间的时间间隔; 参数三:mf.destory传递的参数; 参数四:true 强制删除
    public int deleteExpiredFileByTime(final long expiredTime,final int deleteFilesInterval, final long intervalForcibly, final boolean cleanImmediately)
  • deleteExpiredFileByOffset():CQ 删除过期文件,遍历每个 MF 文件,获取当前文件最后一个数据单元的物理偏移量,小于 offset 说明当前 MF 文件内都是过期数据

    1
    2
    3
    // 参数一:consumeLog 目录下最小物理偏移量,就是第一条消息的 offset; 
    // 参数二:ConsumerQueue 文件内每个数据单元固定大小
    public int deleteExpiredFileByOffset(long offset, int unitSize)

CommitLog

成员属性

成员变量:

  • 魔数:

    1
    2
    public final static int MESSAGE_MAGIC_CODE = -626843481;	// 消息的第一个字段是大小,第二个字段就是魔数	
    protected final static int BLANK_MAGIC_CODE = -875286124; // 文件尾消息的魔法值
  • MappedFileQueue:用于管理 ../store/commitlog 目录下的文件

    1
    protected final MappedFileQueue mappedFileQueue;
  • 存储服务:

    1
    2
    protected final DefaultMessageStore defaultMessageStore;	// 存储模块对象,上层服务
    private final FlushCommitLogService flushCommitLogService; // 刷盘服务,默认实现是异步刷盘
  • 回调器:控制消息的哪些字段添加到 MappedFile

    1
    private final AppendMessageCallback appendMessageCallback;
  • 队列偏移量字典表:key 是主题队列 id,value 是偏移量

    1
    protected HashMap<String, Long> topicQueueTable = new HashMap<String, Long>(1024);
  • 锁相关:

    1
    2
    private volatile long beginTimeInLock = 0;		 	// 写数据时加锁的开始时间
    protected final PutMessageLock putMessageLock; // 写锁,两个实现类:自旋锁和重入锁

    因为发送消息是需要持久化的,在 Broker 端持久化时会获取该锁,保证发送的消息的线程安全

构造方法:

  • 有参构造:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public CommitLog(final DefaultMessageStore defaultMessageStore) {
    // 创建 MappedFileQueue 对象
    // 参数1:../store/commitlog; 参数2:【1g】; 参数3:allocateMappedFileService
    this.mappedFileQueue = new MappedFileQueue(...);
    // 默认 异步刷盘,创建这个对象
    this.flushCommitLogService = new FlushRealTimeService();
    // 控制消息哪些字段追加到 mappedFile,【消息最大是 4M】
    this.appendMessageCallback = new DefaultAppendMessageCallback(...);
    // 默认使用自旋锁
    this.putMessageLock = ...;
    }

成员方法

CommitLog 类核心方法:

  • start():会启动刷盘服务

    1
    public void start()
  • shutdown():关闭刷盘服务

    1
    public void shutdown()
  • load():加载 CommitLog 目录下的文件

    1
    public boolean load()
  • getMessage():根据 offset 查询单条信息,返回的结果对象内部封装了一个 ByteBuffer,该 Buffer 表示 [offset, offset + size] 区间的 MappedFile 的数据

    1
    public SelectMappedBufferResult getMessage(final long offset, final int size)
  • deleteExpiredFile():删除过期文件,方法由 DefaultMessageStore 的定时任务调用

    1
    public int deleteExpiredFile()
  • asyncPutMessage():存储消息

    1
    public CompletableFuture<PutMessageResult> asyncPutMessage(final MessageExtBrokerInner msg)
    • msg.setStoreTimestamp(System.currentTimeMillis()):设置存储时间,后面获取到写锁后这个事件会重写
    • msg.setBodyCRC(UtilAll.crc32(msg.getBody())):获取消息的 CRC 值
    • topic、queueId:获取主题和队列 ID
    • if (msg.getDelayTimeLevel() > 0) 获取消息的延迟级别,这里是延迟消息实现的关键
    • topic = TopicValidator.RMQ_SYS_SCHEDULE_TOPIC修改消息的主题为 SCHEDULE_TOPIC_XXXX
    • queueId = ScheduleMessageService.delayLevel2QueueId()队列 ID 为延迟级别 -1
    • MessageAccessor.putProperty将原来的消息主题和 ID 存入消息的属性 REAL_TOPIC
    • mappedFile = this.mappedFileQueue.getLastMappedFile():获取当前顺序写的 MappedFile 对象
    • putMessageLock.lock()获取写锁
    • msg.setStoreTimestamp(beginLockTimestamp):设置消息的存储时间为获取锁的时间
    • if (null == mappedFile || mappedFile.isFull()):文件写满了创建新的 MF 对象
    • result = mappedFile.appendMessage(msg, this.appendMessageCallback)消息追加,核心逻辑在回调器类
    • putMessageLock.unlock():释放写锁
    • this.defaultMessageStore.unlockMappedFile(..):将 MappedByteBuffer 从 lock 切换为 unlock 状态
    • putMessageResult = new PutMessageResult(PutMessageStatus.PUT_OK, result):结果封装
    • flushResultFuture = submitFlushRequest(result, msg)唤醒刷盘线程
    • replicaResultFuture = submitReplicaRequest(result, msg):HA 消息同步
  • recoverNormally():正常关机时的恢复方法,存储模块启动时先恢复所有的 ConsumeQueue 数据,再恢复 CommitLog 数据

    1
    2
    // 参数表示恢复阶段 ConsumeQueue 中已知的最大的消息 offset
    public void recoverNormally(long maxPhyOffsetOfConsumeQueue)
    • int index = mappedFiles.size() - 3:从倒数第三个 file 开始向后恢复

    • dispatchRequest = this.checkMessageAndReturnSize():每次从切片内解析出一条 msg 封装成 DispatchRequest 对象

    • size = dispatchRequest.getMsgSize():获取消息的大小,检查 DispatchRequest 对象的状态

      情况 1:正常数据,则 mappedFileOffset += size

      情况 2:文件尾数据,处理下一个文件,mappedFileOffset 置为 0,magic_code 表示文件尾

    • processOffset += mappedFileOffset:计算出正确的数据存储位点,并设置 MappedFileQueue 的目录刷盘位点

    • this.mappedFileQueue.truncateDirtyFiles(processOffset):调整 MFQ 中文件的刷盘位点

    • if (maxPhyOffsetOfConsumeQueue >= processOffset):删除冗余数据,将超过全局位点的 CQ 下的文件删除,将包含全局位点的 CQ 下的文件重新定位

  • recoverAbnormally():异常关机时的恢复方法

    1
    public void recoverAbnormally(long maxPhyOffsetOfConsumeQueue)
    • int index = mappedFiles.size() - 1:从尾部开始遍历 MFQ,验证 MF 的第一条消息,找到第一个验证通过的文件对象
    • dispatchRequest = this.checkMessageAndReturnSize():每次解析出一条 msg 封装成 DispatchRequest 对象
    • this.defaultMessageStore.doDispatch(dispatchRequest)重建 ConsumerQueue 和 Index,避免上次异常停机导致 CQ 和 Index 与 CommitLog 不对齐
    • 剩余逻辑与正常关机的恢复方法相似

服务线程

AppendMessageCallback 消息追加服务实现类为 DefaultAppendMessageCallback

  • doAppend():

    1
    public AppendMessageResult doAppend()
    • long wroteOffset = fileFromOffset + byteBuffer.position():消息写入的位置,物理偏移量 phyOffset
    • String msgId消息 ID,规则是客户端 IP + 消息偏移量 phyOffset
    • byte[] topicData:序列化消息,将消息的字段压入到 msgStoreItemMemory 这个 Buffer 中
    • byteBuffer.put(this.msgStoreItemMemory.array(), 0, msgLen):将 msgStoreItemMemory 中的数据写入 MF 对象的内存映射的 Buffer 中,数据还没落盘
    • AppendMessageResult result:构造结果对象,包括存储位点、是否成功、队列偏移量等信息
    • CommitLog.this.topicQueueTable.put(key, ++queueOffset):更新队列偏移量

FlushRealTimeService 刷盘 CL 数据,默认是异步刷盘类 FlushRealTimeService

  • run():运行方法

    1
    public void run()
    • while (!this.isStopped()):stopped为 true 才跳出循环

    • boolean flushCommitLogTimed:控制线程的休眠方式,默认是 false,使用 CountDownLatch.await() 休眠,设置为 true 时使用 Thread.sleep() 休眠

    • int interval:获取配置中的刷盘时间间隔

    • int flushPhysicQueueLeastPages获取最小刷盘页数,默认是 4 页,脏页达到指定页数才刷盘

    • int flushPhysicQueueThoroughInterval:获取强制刷盘周期,默认是 10 秒,达到周期后强制刷盘,不考虑脏页

    • if (flushCommitLogTimed)休眠逻辑,避免 CPU 占用太长时间,导致无法执行其他更紧急的任务

    • CommitLog.this.mappedFileQueue.flush(flushPhysicQueueLeastPages)刷盘

    • for (int i = 0; i < RETRY_TIMES_OVER && !result; i++):stopped 停止标记为 true 时,需要确保所有的数据都已经刷盘,所以此处尝试 10 次强制刷盘,

      result = CommitLog.this.mappedFileQueue.flush(0)强制刷盘

同步刷盘类 GroupCommitService

  • run():运行方法

    1
    public void run()
    • while (!this.isStopped()):stopped为 true 才跳出循环

      this.waitForRunning(10):线程休眠 10 毫秒,最后调用 onWaitEnd() 进行请求的交换 swapRequests()

      this.doCommit():做提交逻辑

      • if (!this.requestsRead.isEmpty()) :读请求集合不为空

        for (GroupCommitRequest req : this.requestsRead):遍历所有的读请求,请求中的属性:

        • private final long nextOffset:本条消息存储之后,下一条消息开始的 offset
        • private CompletableFuture<PutMessageStatus> flushOKFuture:Future 对象

        boolean flushOK = ...:当前请求关注的数据是否全部落盘,落盘成功唤醒消费者线程

        for (int i = 0; i < 2 && !flushOK; i++):尝试进行两次强制刷盘,保证刷盘成功

        CommitLog.this.mappedFileQueue.flush(0):强制刷盘

        req.wakeupCustomer(flushOK ? ...):设置 Future 结果,在 Future 阻塞的线程在这里会被唤醒

        this.requestsRead.clear():清理 reqeustsRead 列表,方便交换时成为 requestsWrite 使用

      • else:读请求集合为空

        CommitLog.this.mappedFileQueue.flush(0):强制刷盘

    • this.swapRequests():交换读写请求

    • this.doCommit():交换后做一次提交


ConsQueue

成员属性

ConsumerQueue 是消息消费队列,存储消息在 CommitLog 的索引,便于快速定位消息

成员变量:

  • 数据单元:ConsumerQueueData 数据单元的固定大小是 20 字节,默认申请 20 字节的缓冲区

    1
    public static final int CQ_STORE_UNIT_SIZE = 20;
  • 文件管理:

    1
    2
    3
    private final MappedFileQueue mappedFileQueue;	// 文件管理器,管理 CQ 目录下的文件
    private final String storePath; // 目录,比如../store/consumequeue/xxx_topic/0
    private final int mappedFileSize; // 每一个 CQ 存储文件大小,默认 20 * 30w = 600w byte
  • 存储主模块:上层的对象

    1
    private final DefaultMessageStore defaultMessageStore;
  • 消息属性:

    1
    2
    3
    4
    5
    private final String topic;					// CQ 主题
    private final int queueId; // CQ 队列,每一个队列都有一个 ConsumeQueue 对象进行管理
    private final ByteBuffer byteBufferIndex; // 临时缓冲区,插新的 CQData 时使用
    private long maxPhysicOffset = -1; // 当前ConsumeQueue内存储的最大消息物理偏移量
    private volatile long minLogicOffset = 0; // 当前ConsumeQueue内存储的最小消息物理偏移量

构造方法:

  • 有参构造:

    1
    2
    3
    4
    public ConsumeQueue() {
    // 申请了一个 20 字节大小的 临时缓冲区
    this.byteBufferIndex = ByteBuffer.allocate(CQ_STORE_UNIT_SIZE);
    }

成员方法

ConsumeQueue 启动阶段方法:

  • load():第一步,加载 storePath 目录下的文件,初始化 MappedFileQueue
  • recover():第二步,恢复 ConsumeQueue 数据
    • 从倒数第三个 MF 文件开始向后遍历,依次读取 MF 中 20 个字节的 CQData 数据,检查 offset 和 size 是否是有效数据
    • 找到无效的 CQData 的位点,该位点就是 CQ 的刷盘点和数据顺序写入点
    • 删除无效的 MF 文件,调整当前顺序写的 MF 文件的数据位点

其他方法:

  • truncateDirtyLogicFiles():CommitLog 恢复阶段调用,将 ConsumeQueue 有效数据文件与 CommitLog 对齐,将超出部分的数据文删除掉,并调整当前文件的数据位点。Broker 启动阶段先恢复 CQ 的数据,再恢复 CL 数据,但是数据要以 CL 为基准

    1
    2
    // 参数是最大消息物理偏移量
    public void truncateDirtyLogicFiles(long phyOffet)
  • flush():刷盘,调用 MFQ 的刷盘方法

    1
    public boolean flush(final int flushLeastPages)
  • deleteExpiredFile():删除过期文件,将小于 offset 的所有 MF 文件删除,offset 是 CommitLog 目录下最小的物理偏移量,小于该值的 CL 文件已经没有了,所以 CQ 也没有存在的必要

    1
    public int deleteExpiredFile(long offset)
  • putMessagePositionInfoWrapper():向 CQ 中追加 CQData 数据,由存储主模块 DefaultMessageStore 内部的异步线程调用,负责构建 ConsumeQueue 文件和 Index 文件的,该线程会持续关注 CommitLog 文件,当 CommitLog 文件内有新数据写入,就读出来封装成 DispatchRequest 对象,转发给 ConsumeQueue 或者 IndexService

    1
    public void putMessagePositionInfoWrapper(DispatchRequest request)
  • getIndexBuffer():转换 startIndex 为 offset,获取包含该 offset 的 MappedFile 文件,读取 [offset%maxSize, mfPos] 范围的数据,包装成结果对象返回

    1
    public SelectMappedBufferResult getIndexBuffer(final long startIndex)

IndexFile

成员属性

IndexFile 类成员属性

  • 哈希:

    1
    2
    private static int hashSlotSize = 4;	// 每个 hash 桶的大小是 4 字节,【用来存放索引的编号】
    private final int hashSlotNum; // hash 桶的个数,默认 500 万
  • 索引:

    1
    2
    3
    4
    private static int indexSize = 20;		// 每个 index 条目的大小是 20 字节
    private static int invalidIndex = 0; // 无效索引编号:0 特殊值
    private final int indexNum; // 默认值:2000w
    private final IndexHeader indexHeader; // 索引头
  • 映射:

    1
    2
    3
    private final MappedFile mappedFile;			// 【索引文件使用的 MF 文件】
    private final FileChannel fileChannel; // 文件通道
    private final MappedByteBuffer mappedByteBuffer;// 从 MF 中获取的内存映射缓冲区

构造方法:

  • 有参构造

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // endPhyOffset 上个索引文件 最后一条消息的 物理偏移量
    // endTimestamp 上个索引文件 最后一条消息的 存储时间
    public IndexFile(final String fileName, final int hashSlotNum, final int indexNum,
    final long endPhyOffset, final long endTimestamp) throws IOException {
    // 文件大小 40 + 500w * 4 + 2000w * 20
    int fileTotalSize =
    IndexHeader.INDEX_HEADER_SIZE + (hashSlotNum * hashSlotSize) + (indexNum * indexSize);
    // 创建 mf 对象,会在disk上创建文件
    this.mappedFile = new MappedFile(fileName, fileTotalSize);
    // 创建 索引头对象,传递 索引文件mf 的切片数据
    this.indexHeader = new IndexHeader(byteBuffer);
    //...
    }

成员方法

IndexFile 类方法

  • load():加载 IndexHeader

    1
    public void load()
  • flush():MappedByteBuffer 内的数据强制落盘

    1
    public void flush()
  • isWriteFull():检查当前的 IndexFile 已写索引数是否 >= indexNum,达到该值则当前 IndexFile 不能继续追加 IndexData 了

    1
    public boolean isWriteFull()
  • destroy():删除文件时使用的方法

    1
    public boolean destroy(final long intervalForcibly)
  • putKey():添加索引数据,解决哈希冲突使用头插法

    1
    2
    3
    // 参数一:消息的 key,uniq_key 或者 keys="aaa bbb ccc" 会分别为 aaa bbb ccc 创建索引
    // 参数二:消息的物理偏移量; 参数三:消息存储时间
    public boolean putKey(final String key, final long phyOffset, final long storeTimestamp)
    • int slotPos = keyHash % this.hashSlotNum:对 key 计算哈希后,取模得到对应的哈希槽 slot 下标,然后计算出哈希槽的存储位置 absSlotPos
    • int slotValue = this.mappedByteBuffer.getInt(absSlotPos):获取槽中的值,如果是无效值说明没有哈希冲突
    • timeDiff = timeDiff / 1000:计算当前 msg 存储时间减去索引文件内第一条消息存储时间的差值,转化为秒进行存储
    • int absIndexPos:计算当前索引数据存储的位置,开始填充索引数据到对应的位置
    • this.mappedByteBuffer.putInt(absIndexPos + 4 + 8 + 4, slotValue)hash 桶的原值,头插法
    • this.mappedByteBuffer.putInt(absSlotPos, this.indexHeader...):在 slot 放入当前索引的索引编号
    • if (this.indexHeader.getIndexCount() <= 1):索引文件插入的第一条数据,需要设置起始偏移量和存储时间
    • if (invalidIndex == slotValue):没有哈希冲突,说明占用了一个新的 hash slot
    • this.indexHeader:设置索引头的相关属性
  • selectPhyOffset():从索引文件查询消息的物理偏移量

    1
    2
    // 参数一:查询结果全部放到该list内; 参数二:查询key; 参数三:结果最大数限制; 参数四五:时间范围
    public void selectPhyOffset(final List<Long> phyOffsets, final String key, final int maxNum,final long begin, final long end, boolean lock)
    • if (this.mappedFile.hold()): MF 的引用记数 +1,查询期间 MF 资源不能被释放
    • int slotValue = this.mappedByteBuffer.getInt(absSlotPos):获取槽中的值,可能是无效值或者索引编号,如果是无效值说明查询未命中
    • int absIndexPos:计算出索引编号对应索引数据的开始位点
    • this.mappedByteBuffer:读取索引数据
    • long timeRead = this.indexHeader.getBeginTimestamp() + timeDiff:计算出准确的存储时间
    • boolean timeMatched = (timeRead >= begin) && (timeRead <= end):时间范围的匹配
    • phyOffsets.add(phyOffsetRead):将命中的消息索引的消息偏移量加入到 list 集合中
    • nextIndexToRead = prevIndexRead:遍历前驱节点

IndexServ

成员属性

IndexService 类用来管理 IndexFile 文件

成员变量:

  • 存储主模块:

    1
    private final DefaultMessageStore defaultMessageStore;
  • 索引文件存储目录:../store/index

    1
    private final String storePath;
  • 索引对象集合:目录下的每个文件都有一个 IndexFile 对象

    1
    private final ArrayList<IndexFile> indexFileList = new ArrayList<IndexFile>();
  • 索引文件:

    1
    2
    private final int hashSlotNum;		// 每个索引文件包含的 哈希桶数量 :500w
    private final int indexNum; // 每个索引文件包含的 索引条目数量 :2000w

成员方法
  • load():加载 storePath 目录下的文件,为每个文件创建一个 IndexFile 实例对象,并加载 IndexHeader 信息

    1
    public boolean load(final boolean lastExitOK)
  • deleteExpiredFile():删除过期索引文件

    1
    2
    // 参数 offset 表示 CommitLog 内最早的消息的 phyOffset
    public void deleteExpiredFile(long offset)
    • this.readWriteLock.readLock().lock():加锁判断
    • long endPhyOffset = this.indexFileList.get(0).getEndPhyOffset():获取目录中第一个文件的结束偏移量
    • if (endPhyOffset < offset):索引目录内存在过期的索引文件,并且当前的 IndexFile 都是过期的数据
    • for (int i = 0; i < (files.length - 1); i++):遍历文件列表,删除过期的文件
  • buildIndex():存储主模块 DefaultMessageStore 内部的异步线程调用,构建 Index 数据

    1
    public void buildIndex(DispatchRequest req)
    • indexFile = retryGetAndCreateIndexFile():获取或者创建顺序写的索引文件对象

    • buildKey(topic, req.getUniqKey())构建索引 key,topic + # + uniqKey

    • indexFile = putKey():插入索引文件

    • if (keys != null && keys.length() > 0):消息存在自定义索引 keys

      for (int i = 0; i < keyset.length; i++):遍历每个索引,为每个 key 调用一次 putKey

  • getAndCreateLastIndexFile():获取当前顺序写的 IndexFile,没有就创建

    1
    public IndexFile getAndCreateLastIndexFile()

HAService

HAService
Service

HAService 类成员变量:

  • 主节点属性:

    1
    2
    3
    4
    5
    6
    // master 节点当前有多少个 slave 节点与其进行数据同步
    private final AtomicInteger connectionCount = new AtomicInteger(0);
    // master 节点会给每个发起连接的 slave 节点的通道创建一个 HAConnection,【控制 master 端向 slave 端传输数据】
    private final List<HAConnection> connectionList = new LinkedList<>();
    // master 向 slave 节点推送的最大的 offset,表示数据同步的进度
    private final AtomicLong push2SlaveMaxOffset = new AtomicLong(0)
  • 内部类属性:

    1
    2
    3
    4
    5
    6
    // 封装了绑定服务器指定端口,监听 slave 的连接的逻辑,没有使用 Netty,使用了原生态的 NIO 去做
    private final AcceptSocketService acceptSocketService;
    // 控制生产者线程阻塞等待的逻辑
    private final GroupTransferService groupTransferService;
    // slave 节点的客户端对象,【slave 端才会正常运行该实例】
    private final HAClient haClient;
  • 线程通信对象:

    1
    private final WaitNotifyObject waitNotifyObject = new WaitNotifyObject()

成员方法:

  • start():启动高可用服务

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public void  start() throws Exception {
    // 监听从节点
    this.acceptSocketService.beginAccept();
    // 启动监听服务
    this.acceptSocketService.start();
    // 启动转移服务
    this.groupTransferService.start();
    // 启动从节点客户端实例
    this.haClient.start();
    }

Accept

AcceptSocketService 类用于监听从节点的连接,创建 HAConnection 连接对象

成员变量:

  • 端口信息:Master 绑定监听的端口信息

    1
    private final SocketAddress socketAddressListen;
  • 服务端通道:

    1
    private ServerSocketChannel serverSocketChannel;
  • 多路复用器:

    1
    private Selector selector;

成员方法:

  • beginAccept():开始监听连接,NIO 标准模板

    1
    public void beginAccept()
  • run():服务启动

    1
    public void run()
    • this.selector.select(1000):多路复用器阻塞获取就绪的通道,最多等待 1 秒钟
    • Set<SelectionKey> selected = this.selector.selectedKeys():获取选择器中所有注册的通道中已经就绪好的事件
    • for (SelectionKey k : selected):遍历所有就绪的事件
    • if ((k.readyOps() & SelectionKey.OP_ACCEPT) != 0):说明 OP_ACCEPT 事件就绪
    • SocketChannel sc = ((ServerSocketChannel) k.channel()).accept()获取到客户端连接的通道
    • HAConnection conn = new HAConnection(HAService.this, sc)为每个连接 master 服务器的 slave 创建连接对象
    • conn.start()启动 HAConnection 对象,内部启动两个服务为读数据服务、写数据服务
    • HAService.this.addConnection(conn):加入到 HAConnection 集合内

Group

GroupTransferService 用来控制数据同步

成员方法:

  • doWaitTransfer():等待主从数据同步

    1
    private void doWaitTransfer()
    • if (!this.requestsRead.isEmpty()):读请求不为空
    • boolean transferOK = HAService.this.push2SlaveMaxOffset... >= req.getNextOffset()主从同步是否完成
    • req.wakeupCustomer(transferOK ? ...):唤醒消费者
    • this.requestsRead.clear():清空读请求
  • swapRequests():交换读写请求

    1
    private void swapRequests()

HAClient
成员属性

HAClient 是 slave 端运行的代码,用于和 master 服务器建立长连接,上报本地同步进度,消费服务器发来的 msg 数据

成员变量:

  • 缓冲区:

    1
    2
    3
    private static final int READ_MAX_BUFFER_SIZE = 1024 * 1024 * 4;	// 默认大小:4 MB
    private ByteBuffer byteBufferRead = ByteBuffer.allocate(READ_MAX_BUFFER_SIZE);
    private ByteBuffer byteBufferBackup = ByteBuffer.allocate(READ_MAX_BUFFER_SIZE);
  • 主节点地址:格式为 ip:port

    1
    private final AtomicReference<String> masterAddress = new AtomicReference<>()
  • NIO 属性:

    1
    2
    3
    private final ByteBuffer reportOffset;	// 通信使用NIO,所以消息使用块传输,上报 slave offset 使用
    private SocketChannel socketChannel; // 客户端与 master 的会话通道
    private Selector selector; // 多路复用器
  • 通信时间:上次会话通信时间,用于控制 socketChannel 是否关闭的

    1
    private long lastWriteTimestamp = System.currentTimeMillis();
  • 进度信息:

    1
    2
    private long currentReportedOffset = 0;	// slave 当前的进度信息
    private int dispatchPosition = 0; // 控制 byteBufferRead position 指针

成员方法
  • run():启动方法

    1
    public void run()
    • if (this.connectMaster()):连接主节点,连接失败会休眠 5 秒

      • String addr = this.masterAddress.get():获取 master 暴露的 HA 地址端口信息
      • this.socketChannel = RemotingUtil.connect(socketAddress):建立连接
      • this.socketChannel.register(this.selector, SelectionKey.OP_READ):注册到多路复用器,关注读事件
      • this.currentReportedOffset: 初始化上报进度字段为 slave 的 maxPhyOffset
    • if (this.isTimeToReportOffset()):slave 每 5 秒会上报一次 slave 端的同步进度信息给 master

      boolean result = this.reportSlaveMaxOffset()上报同步信息,上报失败关闭连接

    • this.selector.select(1000):多路复用器阻塞获取就绪的通道,最多等待 1 秒钟,获取到就绪事件或者超时后结束

    • boolean ok = this.processReadEvent():处理读事件

    • if (!reportSlaveMaxOffsetPlus()):检查是否重新上报同步进度

  • reportSlaveMaxOffset():上报 slave 同步进度

    1
    private boolean reportSlaveMaxOffset(final long maxOffset)
    • 首先向缓冲区写入 slave 端最大偏移量,写完以后切换为指定置为初始状态

    • for (int i = 0; i < 3 && this.reportOffset.hasRemaining(); i++):尝试三次写数据

      this.socketChannel.write(this.reportOffset)写数据

    • return !this.reportOffset.hasRemaining():写成功之后 pos = limit

  • processReadEvent():处理 master 发送给 slave 数据,返回 true 表示处理成功 false 表示 Socket 处于半关闭状态,需要上层重建 haClient

    1
    private boolean processReadEvent()
    • int readSizeZeroTimes = 0:控制 while 循环的一个条件变量,当值为 3 时跳出循环

    • while (this.byteBufferRead.hasRemaining()):byteBufferRead 有空间可以去 Socket 读缓冲区加载数据

    • int readSize = this.socketChannel.read(this.byteBufferRead)从通道读数据

    • if (readSize > 0):加载成功,有新数据

      readSizeZeroTimes = 0:置为 0

      boolean result = this.dispatchReadRequest():处理数据的核心逻辑

    • else if (readSize == 0) :连续无新数据 3 次,跳出循环

    • else:readSize = -1 就表示 Socket 处于半关闭状态,对端已经关闭了

  • dispatchReadRequest():处理数据的核心逻辑,master 与 slave 传输的数据格式 {[phyOffset][size][data...]},phyOffset 表示数据区间的开始偏移量,data 代表数据块,最大 32kb,可能包含多条消息的数据

    1
    private boolean dispatchReadRequest()
    • final int msgHeaderSize = 8 + 4:协议头大小 12

    • int readSocketPos = this.byteBufferRead.position():记录缓冲区处理数据前的 pos 位点,用于恢复指针

    • int diff = ...:当前 byteBufferRead 还剩多少 byte 未处理,每处理一条帧数据都会更新 dispatchPosition

    • if (diff >= msgHeaderSize):缓冲区还有完整的协议头 header 数据

    • if (diff >= (msgHeaderSize + bodySize)):说明缓冲区内是包含当前帧的全部数据的,开始处理帧数据

      HAService...appendToCommitLog(masterPhyOffset, bodyData)存储数据到 CommitLog,并构建 Index 和 CQ

      this.byteBufferRead.position(readSocketPos):恢复 byteBufferRead 的 pos 指针

      this.dispatchPosition += msgHeaderSize + bodySize:加一帧数据长度,处理下一条数据使用

      if (!reportSlaveMaxOffsetPlus()):上报 slave 同步信息

    • if (!this.byteBufferRead.hasRemaining()):缓冲区写满了,重新分配缓冲区

  • reallocateByteBuffer():重新分配缓冲区

    1
    private void reallocateByteBuffer()
    • int remain = READ_MAX_BUFFER_SIZE - this.dispatchPosition:表示缓冲区尚未处理过的字节数量

    • if (remain > 0):条件成立,说明缓冲区最后一帧数据是半包数据,但是不能丢失数据

      this.byteBufferBackup.put(this.byteBufferRead)将半包数据拷贝到 backup 缓冲区

    • this.swapByteBuffer():交换 backup 成为 read

    • this.byteBufferRead.position(remain):设置 pos 为 remain ,后续加载数据 pos 从remain 开始向后移动

    • this.dispatchPosition = 0:当前缓冲区交换之后,相当于是一个全新的 byteBuffer,所以分配指针归零


HAConn
Connection

HAConnection 类成员变量:

  • 会话通道:master 和 slave 之间通信的 SocketChannel

    1
    private final SocketChannel socketChannel;
  • 客户端地址:

    1
    private final String clientAddr;
  • 服务类:

    1
    2
    private WriteSocketService writeSocketService;	// 写数据服务
    private ReadSocketService readSocketService; // 读数据服务
  • 请求位点:在 slave 上报本地的进度之后被赋值,该值大于 0 后同步逻辑才会运行,master 如果不知道 slave 节点当前消息的存储进度,就无法给 slave 推送数据

    1
    private volatile long slaveRequestOffset = -1;
  • 应答位点: 保存最新的 slave 上报的 offset 信息,slaveAckOffset 之前的数据都可以认为 slave 已经同步完成

    1
    private volatile long slaveAckOffset = -1;

核心方法:

  • 构造方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public HAConnection(final HAService haService, final SocketChannel socketChannel) {
    // 初始化一些东西
    // 设置 socket 读写缓冲区为 64kb 大小
    this.socketChannel.socket().setReceiveBufferSize(1024 * 64);
    this.socketChannel.socket().setSendBufferSize(1024 * 64);
    // 创建读写服务
    this.writeSocketService = new WriteSocketService(this.socketChannel);
    this.readSocketService = new ReadSocketService(this.socketChannel);
    // 自增
    this.haService.getConnectionCount().incrementAndGet();
    }
  • 启动方法:

    1
    2
    3
    4
    public void start() {
    this.readSocketService.start();
    this.writeSocketService.start();
    }

ReadSocket

ReadSocketService 类是一个任务对象,slave 向 master 传输的帧格式为 [long][long][long],上报的是 slave 本地的同步进度,同步进度是一个 long 值

成员变量:

  • 读缓冲:

    1
    2
    private static final int READ_MAX_BUFFER_SIZE = 1024 * 1024;	// 默认大小 1MB
    private final ByteBuffer byteBufferRead = ByteBuffer.allocate(READ_MAX_BUFFER_SIZE);
  • NIO 属性:

    1
    2
    private final Selector selector;			// 多路复用器
    private final SocketChannel socketChannel; // master 与 slave 之间的会话 SocketChannel
  • 处理位点:缓冲区处理位点

    1
    private int processPosition = 0;
  • 上次读操作的时间:

    1
    private volatile long lastReadTimestamp = System.currentTimeMillis();

核心方法:

  • 构造方法:

    1
    public ReadSocketService(final SocketChannel socketChannel)
    • this.socketChannel.register(this.selector, SelectionKey.OP_READ):通道注册到多路复用器,关注读事件
    • this.setDaemon(true):设置为守护线程
  • 运行方法:

    1
    public void run()
    • this.selector.select(1000):多路复用器阻塞获取就绪的通道,最多等待 1 秒钟,获取到就绪事件或者超时后结束

    • boolean ok = this.processReadEvent()读数据的核心方法,返回 true 表示处理成功 false 表示 Socket 处于半关闭状态,需要上层重建 HAConnection 对象

      • int readSizeZeroTimes = 0:控制 while 循环,当连续从 Socket 读取失败 3 次(未加载到数据)跳出循环

      • if (!this.byteBufferRead.hasRemaining()):byteBufferRead 已经全部使用完,需要清理数据并更新位点

      • while (this.byteBufferRead.hasRemaining()):byteBufferRead 有空间可以去 Socket 读缓冲区加载数据

      • int readSize = this.socketChannel.read(this.byteBufferRead)从通道读数据

      • if (readSize > 0):加载成功,有新数据

        if ((byteBufferRead.position() - processPosition) >= 8):缓冲区的可读数据最少包含一个数据帧

        • int pos = ...获取可读帧数据中最后一个完整的帧数据的位点,后面的数据丢弃
      • long readOffset = ...byteBufferRead.getLong(pos - 8):读取最后一帧数据,slave 端当前的同步进度信息

        • this.processPosition = pos:更新处理位点
        • HAConnection.this.slaveAckOffset = readOffset:更新应答位点
        • if (HAConnection.this.slaveRequestOffset < 0):条件成立给 slaveRequestOffset 赋值
        • HAConnection...notifyTransferSome(slaveAckOffset)唤醒阻塞的生产者线程
      • else if (readSize == 0) :读取 3 次无新数据跳出循环

      • else:readSize = -1 就表示 Socket 处于半关闭状态,对端已经关闭了

    • if (interval > 20):超过 20 秒未发生通信,直接结束循环


WriteSocket

WriteSocketService 类是一个任务对象,master 向 slave 传输的数据帧格式为 {[phyOffset][size][data...]}{[phyOffset][size][data...]}

  • phyOffset:数据区间的开始偏移量,并不表示某一条具体的消息,表示的数据块开始的偏移量位置
  • size:同步的数据块的大小
  • data:数据块,最大 32kb,可能包含多条消息的数据

成员变量:

  • 协议头:

    1
    2
    private final int headerSize = 8 + 4;		// 协议头大小:12
    private final ByteBuffer byteBufferHeader; // 帧头缓冲区
  • NIO 属性:

    1
    2
    private final Selector selector;			// 多路复用器
    private final SocketChannel socketChannel; // master 与 slave 之间的会话 SocketChannel
  • 处理位点:下一次传输同步数据的位置信息,master 给当前 slave 同步的位点

    1
    private long nextTransferFromWhere = -1;
  • 上次写操作:

    1
    2
    private boolean lastWriteOver = true;							// 上一轮数据是否传输完毕
    private long lastWriteTimestamp = System.currentTimeMillis(); // 上次写操作的时间

核心方法:

  • 构造方法:

    1
    public WriteSocketService(final SocketChannel socketChannel)
    • this.socketChannel.register(this.selector, SelectionKey.OP_WRITE):通道注册到多路复用器,关注写事件
    • this.setDaemon(true):设置为守护线程
  • 运行方法:

    1
    public void run()
    • this.selector.select(1000):多路复用器阻塞获取就绪的通道,最多等待 1 秒钟,获取到就绪事件或者超时后结束

    • if (-1 == HAConnection.this.slaveRequestOffset)等待 slave 同步完数据

    • if (-1 == this.nextTransferFromWhere):条件成立,需要初始化该变量

      if (0 == HAConnection.this.slaveRequestOffset):slave 是一个全新节点,从正在顺序写的 MF 开始同步数据

      long masterOffset = ...:获取 master 最大的 offset,并计算归属的 mappedFile 文件的开始 offset

      this.nextTransferFromWhere = masterOffset赋值给下一次传输同步数据的位置信息

      this.nextTransferFromWhere = HAConnection.this.slaveRequestOffset:大部分情况走这个赋值逻辑

    • if (this.lastWriteOver):上一次待发送数据全部发送完成

      if (interval > 5)超过 5 秒未同步数据,发送一个 header 心跳数据包,维持长连接

    • else:上一轮的待发送数据未全部发送,需要同步数据到 slave 节点

    • SelectMappedBufferResult selectResult到 CommitLog 中查询 nextTransferFromWhere 开始位置的数据

    • if (size > 32k):一次最多同步 32k 数据

    • this.nextTransferFromWhere += size:增加 size,下一轮传输跳过本帧数据

    • selectResult.getByteBuffer().limit(size):设置 byteBuffer 可访问数据区间为 [pos, size]

    • this.selectMappedBufferResult = selectResult待发送的数据

    • this.byteBufferHeader.put构建帧头数据

    • this.lastWriteOver = this.transferData():处理数据,返回是否处理完成

  • 同步方法:同步数据到 slave 节点,返回 true 表示本轮数据全部同步完成,false 表示本轮同步未完成(Header 和 Body 其中一个未同步完成都会返回 false)

    1
    private boolean transferData()
    • int writeSizeZeroTimes= 0:控制 while 循环,当写失败连续 3 次时,跳出循环)跳出循环

    • while (this.byteBufferHeader.hasRemaining())帧头数据缓冲区有待发送的数据

    • int writeSize = this.socketChannel.write(this.byteBufferHeader):向通道写帧头数据

    • if (null == this.selectMappedBufferResult):说明是心跳数据,返回心跳数据是否发送完成

    • if (!this.byteBufferHeader.hasRemaining())Header写成功之后,才进行写 Body

    • while (this.selectMappedBufferResult.getByteBuffer().hasRemaining())数据缓冲区有待发送的数据

    • int writeSize = this.socketChannel.write(this.selectMappedBufferResult...):向通道写帧头数据

    • if (writeSize > 0):写数据成功,但是不代表 SMBR 中的数据全部写完成

    • boolean result:判断是否发送完成,返回该值


MesStore

生命周期

DefaultMessageStore 类核心是整个存储服务的调度类

  • 构造方法:

    1
    public DefaultMessageStore()
    • this.allocateMappedFileService.start():启动创建 MappedFile 文件服务
    • this.indexService.start():启动索引服务
  • load():先加载 CommitLog,再加载 ConsumeQueue,最后加载 IndexFile,加载完进入恢复阶段,先恢复 CQ,在恢复 CL

    1
    public boolean load()
  • start():核心启动方法

    1
    public void start()
    • lock = lockFile.getChannel().tryLock(0, 1, false):获取文件锁,获取失败说明当前目录已经启动过 Broker

    • long maxPhysicalPosInLogicQueue = commitLog.getMinOffset():遍历全部的 CQ 对象,获取 CQ 中消息的最大偏移量

    • this.reputMessageService.start():设置分发服务的分发位点,启动分发服务,构建 ConsumerQueue 和 IndexFile

    • if (dispatchBehindBytes() <= 0):线程等待分发服务将分发数据全部处理完毕

    • this.recoverTopicQueueTable():因为修改了 CQ 数据,所以再次构建队列偏移量字段表

    • this.haService.start():启动 HA 服务

    • this.handleScheduleMessageService():启动消息调度服务

    • this.flushConsumeQueueService.start():启动 CQ 消费队列刷盘服务

    • this.commitLog.start():启动 CL 刷盘服务

    • this.storeStatsService.start():启动状态存储服务

    • this.createTempFile():创建 AbortFile,正常关机时 JVM HOOK 会删除该文件,异常宕机时该文件不会删除,开机数据恢复阶段根据是否存在该文件,执行不同的恢复策略

    • this.addScheduleTask():添加定时任务

      • DefaultMessageStore.this.cleanFilesPeriodically()定时清理过期文件,周期是 10 秒

        • this.cleanCommitLogService.run():启动清理过期的 CL 文件服务
        • this.cleanConsumeQueueService.run():启动清理过期的 CQ 文件服务
      • DefaultMessageStore.this.checkSelf():每 10 分种进行健康检查

      • DefaultMessageStore.this.cleanCommitLogService.isSpaceFull()磁盘预警定时任务,每 10 秒一次

        • if (physicRatio > this.diskSpaceWarningLevelRatio):检查磁盘是否到达 waring 阈值,默认 90%

          boolean diskok = ...runningFlags.getAndMakeDiskFull():设置磁盘写满标记

        • boolean diskok = ...this.runningFlags.getAndMakeDiskOK():设置磁盘可写标记

    • this.shutdown = false:刚启动,设置为 false

  • shutdown():关闭各种服务和线程资源,设置存储模块状态为关闭状态

    1
    public void shutdown()
  • destroy():销毁 Broker 的工作目录

    1
    public void destroy()

服务线程

ServiceThread 类被很多服务继承,本身是一个 Runnable 任务对象,继承者通过重写 run 方法来实现服务的逻辑

  • run():一般实现方式

    1
    2
    3
    4
    5
    public void run() {
    while (!this.isStopped()) {
    // 业务逻辑
    }
    }

    通过参数 stopped 控制服务的停止,使用 volatile 修饰保证可见性

    1
    protected volatile boolean stopped = false
  • shutdown():停止线程,首先设置 stopped 为 true,然后进行唤醒,默认不直接打断线程

    1
    public void shutdown()
  • waitForRunning():挂起线程,设置唤醒标记 hasNotified 为 false

    1
    protected void waitForRunning(long interval)
  • wakeup():唤醒线程,设置 hasNotified 为 true

    1
    public void wakeup()

构建服务

AllocateMappedFileService 创建 MappedFile 服务

  • mmapOperation():核心服务

    1
    private boolean mmapOperation()
    • req = this.requestQueue.take()从 requestQueue 阻塞队列(优先级)中获取 AllocateRequest 任务
    • if (...isTransientStorePoolEnable()):条件成立使用直接内存写入数据, 从直接内存中 commit 到 FileChannel 中
    • mappedFile = new MappedFile(req.getFilePath(), req.getFileSize()):根据请求的路径和大小创建对象
    • mappedFile.warmMappedFile():判断 mappedFile 大小,只有 CommitLog 才进行文件预热
    • req.setMappedFile(mappedFile):将创建好的 MF 对象的赋值给请求对象的成员属性
    • req.getCountDownLatch().countDown()唤醒请求的阻塞线程
  • putRequestAndReturnMappedFile():MappedFileQueue 中用来创建 MF 对象的方法

    1
    public MappedFile putRequestAndReturnMappedFile(String nextFilePath, String nextNextFilePath, int fileSize)
    • AllocateRequest nextReq = new AllocateRequest(...):创建 nextFilePath 的 AllocateRequest 对象,放入请求列表和阻塞队列,然后创建 nextNextFilePath 的 AllocateRequest 对象,放入请求列表和阻塞队列
    • AllocateRequest result = this.requestTable.get(nextFilePath):从请求列表获取 nextFilePath 的请求对象
    • result.getCountDownLatch().await(...)线程挂起,直到超时或者 nextFilePath 对应的 MF 文件创建完成
    • return result.getMappedFile():返回创建好的 MF 文件对象

ReputMessageService 消息分发服务,用于构建 ConsumerQueue 和 IndexFile 文件

  • run():循环执行 doReput 方法,所以发送的消息存储进 CL 就可以产生对应的 CQ,每执行一次线程休眠 1 毫秒

    1
    public void run()
  • doReput():实现分发的核心逻辑

    1
    private void doReput()
    • for (boolean doNext = true; this.isCommitLogAvailable() && doNext; ):循环遍历
    • SelectMappedBufferResult result: 从 CommitLog 拉取数据,数据范围 [reputFromOffset, 包含该偏移量的 MF 的最大 Pos],封装成结果对象
    • DispatchRequest dispatchRequest:从结果对象读取出一条 DispatchRequest 数据
    • DefaultMessageStore.this.doDispatch(dispatchRequest):将数据交给分发器进行分发,用于构建 CQ 和索引文件
    • this.reputFromOffset += size:更新数据范围

刷盘服务

FlushConsumeQueueService 刷盘 CQ 数据

  • run():每隔 1 秒执行一次刷盘服务,跳出循环后还会执行一次强制刷盘

    1
    public void run()
  • doFlush():刷盘

    1
    private void doFlush(int retryTimes)
    • int flushConsumeQueueLeastPages:脏页阈值,默认是 2

    • if (retryTimes == RETRY_TIMES_OVER)重试次数是 3 时设置强制刷盘,设置脏页阈值为 0

    • int flushConsumeQueueThoroughInterval:两次刷新的时间间隔超过 60 秒会强制刷盘

    • for (ConsumeQueue cq : maps.values()):遍历所有的 CQ,进行刷盘

    • DefaultMessageStore.this.getStoreCheckpoint().flush():强制刷盘时将 StoreCheckpoint 瞬时数据刷盘

FlushCommitLogService 刷盘 CL 数据,默认是异步刷盘

  • run():运行方法

    1
    public void run()
    • while (!this.isStopped()):stopped为 true 才跳出循环

    • boolean flushCommitLogTimed:控制线程的休眠方式,默认是 false,使用 CountDownLatch.await() 休眠,设置为 true 时使用 Thread.sleep() 休眠

    • int interval:获取配置中的刷盘时间间隔

    • int flushPhysicQueueLeastPages:获取最小刷盘页数,默认是 4 页,脏页达到指定页数才刷盘

    • int flushPhysicQueueThoroughInterval:获取强制刷盘周期,默认是 10 秒,达到周期后强制刷盘,不考虑脏页

    • if (flushCommitLogTimed):休眠逻辑,避免 CPU 占用太长时间,导致无法执行其他更紧急的任务

    • CommitLog.this.mappedFileQueue.flush(flushPhysicQueueLeastPages)刷盘

    • for (int i = 0; i < RETRY_TIMES_OVER && !result; i++):stopped 停止标记为 true 时,需要确保所有的数据都已经刷盘,所以此处尝试 10 次强制刷盘,

      result = CommitLog.this.mappedFileQueue.flush(0)强制刷盘


清理服务

CleanCommitLogService 清理过期的 CL 数据,定时任务 10 秒调用一次,先清理 CL,再清理 CQ,因为 CQ 依赖于 CL 的数据

  • run():运行方法

    1
    public void run()
  • deleteExpiredFiles():删除过期 CL 文件

    1
    private void deleteExpiredFiles()
    • long fileReservedTime:默认 72,代表文件的保留时间
    • boolean timeup = this.isTimeToDelete():当前时间是否是凌晨 4 点
    • boolean spacefull = this.isSpaceToDelete():CL 或者 CQ 的目录磁盘使用率达到阈值标准 85%
    • boolean manualDelete = this.manualDeleteFileSeveralTimes > 0:手动删除文件
    • fileReservedTime *= 60 * 60 * 1000:默认保留 72 小时
    • deleteCount = DefaultMessageStore.this.commitLog.deleteExpiredFile()调用 MFQ 对象的删除方法

CleanConsumeQueueService 清理过期的 CQ 数据

  • run():运行方法

    1
    public void run()
  • deleteExpiredFiles():删除过期 CQ 文件

    1
    private void deleteExpiredFiles()
    • int deleteLogicsFilesInterval:清理 CQ 的时间间隔,默认 100 毫秒
    • long minOffset = DefaultMessageStore.this.commitLog.getMinOffset():获取 CL 文件中最小的物理偏移量
    • if (minOffset > this.lastPhysicalMinOffset):CL 最小的偏移量大于 CQ 最小的,说明有过期数据
    • this.lastPhysicalMinOffset = minOffset:更新 CQ 的最小偏移量
    • for (ConsumeQueue logic : maps.values()):遍历所有的 CQ 文件
    • logic.deleteExpiredFile(minOffset)调用 MFQ 对象的删除方法
    • DefaultMessageStore.this.indexService.deleteExpiredFile(minOffset)删除过期的索引文件

获取消息

DefaultMessageStore#getMessage 用于获取消息,在 PullMessageProcessor#processRequest 方法中被调用 (提示:建议学习消费者源码时再阅读)

1
2
// offset: 客户端拉消息使用位点;   maxMsgNums: 32;  messageFilter: 一般这里是 tagCode 过滤 
public GetMessageResult getMessage(final String group, final String topic, final int queueId, final long offset, final int maxMsgNums, final MessageFilter messageFilter)
  • if (this.shutdown):检查运行状态

  • GetMessageResult getResult:创建查询结果对象

  • final long maxOffsetPy = this.commitLog.getMaxOffset()获取 CommitLog 最大物理偏移量

  • ConsumeQueue consumeQueue = findConsumeQueue(topic, queueId):根据主题和队列 ID 获取 ConsumeQueue对象

  • minOffset, maxOffset:获取当前 ConsumeQueue 的最小 offset 和 最大 offset,判断是否满足本次 Pull 的 offset

    if (maxOffset == 0):说明队列内无数据,设置状态为 NO_MESSAGE_IN_QUEUE,外层进行长轮询

    else if (offset < minOffset):说明 offset 太小了,设置状态为 OFFSET_TOO_SMALL

    else if (offset == maxOffset):消费进度持平,设置状态为 OFFSET_OVERFLOW_ONE,外层进行长轮询

    else if (offset > maxOffset):说明 offset 越界了,设置状态为 OFFSET_OVERFLOW_BADLY

  • SelectMappedBufferResult bufferConsumeQueue:查询 CQData 获取包含该 offset 的 MappedFile 文件,如果该文件不是顺序写的文件,就读取 [offset%maxSize, 文件尾] 范围的数据,反之读取 [offset%maxSize, 文件名+wrotePosition尾]

    先查 CQ 的原因:因为 CQ 时 CL 的索引,通过 CQ 查询 CL 更加快捷

  • if (bufferConsumeQueue != null):只有再 CQ 删除过期数据的逻辑执行时,条件才不成立,一般都是成立的

  • long nextPhyFileStartOffset = Long.MIN_VALUE:下一个 commitLog 物理文件名,初始值为最小值

  • long maxPhyOffsetPulling = 0:本次拉消息最后一条消息的物理偏移量

  • for ()处理数据,每次处理 20 字节处理字节数大于 16000 时跳出循环

  • offsetPy, sizePy, tagsCode:读取 20 个字节后,获取消息物理偏移量、消息大小、消息 tagCode

  • boolean isInDisk = checkInDiskByCommitOffset(...)检查消息是热数据还是冷数据,false 为热数据

    • long memory:Broker 系统 40% 内存的字节数,写数据时内存不够会使用 LRU 算法淘汰数据,将淘汰数据持久化到磁盘
    • return (maxOffsetPy - offsetPy) > memory:返回 true 说明数据已经持久化到磁盘,为冷数据
  • if (this.isTheBatchFull())控制是否跳出循环

    • if (0 == bufferTotal || 0 == messageTotal):本次 pull 消息未拉取到任何东西,需要外层 for 循环继续,返回 false

    • if (maxMsgNums <= messageTotal):结果对象内消息数已经超过了最大消息数量,可以结束循环了

    • if (isInDisk):冷数据

      if ((bufferTotal + sizePy) > ...):冷数据一次 pull 请求最大允许获取 64kb 的消息

      if (messageTotal > ...):冷数据一次 pull 请求最大允许获取8 条消息

    • else:热数据

      if ((bufferTotal + sizePy) > ...):热数据一次 pull 请求最大允许获取 256kb 的消息

      if (messageTotal > ...):冷数据一次 pull 请求最大允许获取32 条消息

  • if (messageFilter != null):按照消息 tagCode 进行过滤

  • selectResult = this.commitLog.getMessage(offsetPy, sizePy):根据 CQ 消息物理偏移量和消息大小到 commitLog 中查询这条 msg

  • if (null == selectResult):条件成立说明 commitLog 执行了删除过期文件的定时任务,因为是先清理的 CL,所以 CQ 还有该索引数据

  • nextPhyFileStartOffset = this.commitLog.rollNextFile(offsetPy):获取包含该 offsetPy 的下一个数据文件的文件名

  • getResult.addMessage(selectResult)将本次循环查询出来的 msg 加入到 getResult 内

  • status = GetMessageStatus.FOUND:查询状态设置为 FOUND

  • nextPhyFileStartOffset = Long.MIN_VALUE:设置为最小值,跳过期 CQData 数据的逻辑

  • nextBeginOffset = offset + (i / ConsumeQueue.CQ_STORE_UNIT_SIZE):计算客户端下一次 pull 时使用的位点信息

  • getResult.setSuggestPullingFromSlave(diff > memory)选择主从节点的建议

    • diff > memory => true:表示本轮查询最后一条消息为冷数据,Broker 建议客户端下一次 pull 时到 slave 节点
    • diff > memory => false:表示本轮查询最后一条消息为热数据,Broker 建议客户端下一次 pull 时到 master 节点
  • getResult.setStatus(status):设置结果状态

  • getResult.setNextBeginOffset(nextBeginOffset):设置客户端下一次 pull 时的 offset

  • getResult.setMaxOffset(maxOffset):设置 queue 的最大 offset 和最小 offset

  • return getResult:返回结果对象


Broker

BrokerStartup 启动方法

1
2
3
4
5
6
public static void main(String[] args) {
start(createBrokerController(args));
}
public static BrokerController start(BrokerController controller) {
controller.start(); // 启动
}

BrokerStartup#createBrokerController:构造控制器,并初始化

  • final BrokerController controller():创建实例对象
  • boolean initResult = controller.initialize():控制器初始化
    • this.registerProcessor()注册了处理器,包括发送消息、拉取消息、查询消息等核心处理器
    • initialTransaction():初始化了事务服务,用于进行事务回查

BrokerController#start:核心启动方法

  • this.messageStore.start()启动存储服务

  • this.remotingServer.start():启动 Netty 通信服务

  • this.fileWatchService.start():启动文件监听服务

  • startProcessorByHa(messageStoreConfig.getBrokerRole())启动事务回查

  • this.scheduledExecutorService.scheduleAtFixedRate():每隔 30s 向 NameServer 上报 Topic 路由信息,心跳机制

    BrokerController.this.registerBrokerAll(true, false, brokerConfig.isForceRegister())


Producer

生产者类

生产者类

DefaultMQProducer 是生产者的默认实现类

成员变量:

  • 生产者实现类:

    1
    protected final transient DefaultMQProducerImpl defaultMQProducerImpl
  • 生产者组:发送事务消息,Broker 端进行事务回查(补偿机制)时,选择当前生产者组的下一个生产者进行事务回查

    1
    private String producerGroup;
  • 默认主题:isAutoCreateTopicEnable 开启时,当发送消息指定的 Topic 在 Namesrv 未找到路由信息,使用该值创建 Topic 信息

    1
    2
    private String createTopicKey = TopicValidator.AUTO_CREATE_TOPIC_KEY_TOPIC;
    // 值为【TBW102】,Just for testing or demo program
  • 消息重投:系统特性消息重试部分详解了三个参数的作用

    1
    2
    3
    private int retryTimesWhenSendFailed = 2;		// 同步发送失败后重试的发送次数,加上第一次发送,一共三次
    private int retryTimesWhenSendAsyncFailed = 2; // 异步
    private boolean retryAnotherBrokerWhenNotStoreOK = false; // 消息未存储成功,选择其他 Broker 重试
  • 消息队列:

    1
    private volatile int defaultTopicQueueNums = 4;		// 默认 Broker 创建的队列数
  • 消息属性:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
      private int sendMsgTimeout = 3000;					// 发送消息的超时限制
    private int compressMsgBodyOverHowmuch = 1024 * 4; // 压缩阈值,当 msg body 超过 4k 后使用压缩
    private int maxMessageSize = 1024 * 1024 * 4; // 消息体的最大限制,默认 4M
    private TraceDispatcher traceDispatcher = null; // 消息轨迹

    构造方法:

    * 构造方法:

    ```java
    public DefaultMQProducer(final String namespace, final String producerGroup, RPCHook rpcHook) {
    this.namespace = namespace;
    this.producerGroup = producerGroup;
    // 创建生产者实现对象
    defaultMQProducerImpl = new DefaultMQProducerImpl(this, rpcHook);
    }

成员方法:

  • start():启动方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public void start() throws MQClientException {
    // 重置生产者组名,如果传递了命名空间,则 【namespace%group】
    this.setProducerGroup(withNamespace(this.producerGroup));
    // 生产者实现对象启动
    this.defaultMQProducerImpl.start();
    if (null != traceDispatcher) {
    // 消息轨迹的逻辑
    traceDispatcher.start(this.getNamesrvAddr(), this.getAccessChannel());
    }
    }
  • send():发送消息

    1
    2
    3
    4
    5
    6
    7
    public SendResult send(Message msg){
    // 校验消息
    Validators.checkMessage(msg, this);
    // 设置消息 Topic
    msg.setTopic(withNamespace(msg.getTopic()));
    return this.defaultMQProducerImpl.send(msg);
    }
  • request():请求方法,需要消费者回执消息

    1
    2
    3
    4
    public Message request(final Message msg, final MessageQueue mq, final long timeout) {
    msg.setTopic(withNamespace(msg.getTopic()));
    return this.defaultMQProducerImpl.request(msg, mq, timeout);
    }

实现者类

DefaultMQProducerImpl 类是默认的生产者实现类

成员变量:

  • 实例对象:

    1
    2
    private final DefaultMQProducer defaultMQProducer;	// 持有默认生产者对象,用来获取对象中的配置信息
    private MQClientInstance mQClientFactory; // 客户端实例对象,生产者启动后需要注册到该客户端对象内
  • 主题发布信息映射表:key 是 Topic,value 是发布信息

    1
    private final ConcurrentMap<String, TopicPublishInfo> topicPublishInfoTable = new ConcurrentHashMap<String, TopicPublishInfo>();
  • 异步发送消息:相关信息

    1
    2
    3
    private final BlockingQueue<Runnable> asyncSenderThreadPoolQueue;// 异步发送消息,异步线程池使用的队列
    private final ExecutorService defaultAsyncSenderExecutor; // 异步发送消息默认使用的线程池
    private ExecutorService asyncSenderExecutor; // 异步消息发送线程池,指定后就不使用默认线程池了
  • 定时器:执行定时任务

    1
    private final Timer timer = new Timer("RequestHouseKeepingService", true);	// 守护线程
  • 状态信息:服务的状态,默认创建状态

    1
    private ServiceState serviceState = ServiceState.CREATE_JUST;
  • 压缩等级:ZIP 压缩算法的等级,默认是 5,越高压缩效果好,但是压缩的更慢

    1
    private int zipCompressLevel = Integer.parseInt(System.getProperty..., "5"));
  • 容错策略:选择队列的容错策略

    1
    private MQFaultStrategy mqFaultStrategy = new MQFaultStrategy();
  • 钩子:用来进行前置或者后置处理

    1
    2
    3
    ArrayList<SendMessageHook> sendMessageHookList;			// 发送消息的钩子,留给用户扩展使用
    ArrayList<CheckForbiddenHook> checkForbiddenHookList; // 对比上面的钩子,可以抛异常,控制消息是否可以发送
    private final RPCHook rpcHook; // 传递给 NettyRemotingClient

构造方法:

  • 默认构造:

    1
    2
    3
    4
    public DefaultMQProducerImpl(final DefaultMQProducer defaultMQProducer) {
    // 默认 RPC HOOK 是空
    this(defaultMQProducer, null);
    }
  • 有参构造:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public DefaultMQProducerImpl(final DefaultMQProducer defaultMQProducer, RPCHook rpcHook) {
    // 属性赋值
    this.defaultMQProducer = defaultMQProducer;
    this.rpcHook = rpcHook;

    // 创建【异步消息线程池任务队列】,长度是 5w
    this.asyncSenderThreadPoolQueue = new LinkedBlockingQueue<Runnable>(50000);
    // 创建默认的异步消息任务线程池
    this.defaultAsyncSenderExecutor = new ThreadPoolExecutor(
    // 核心线程数和最大线程数都是 系统可用的计算资源(8核16线程的系统就是 16)...
    }

实现方法
  • start():启动方法,参数默认是 true,代表正常的启动路径

    1
    public void start(final boolean startFactory)
    • this.serviceState = ServiceState.START_FAILED:先修改为启动失败,成功后再修改,这种思想很常见

    • this.checkConfig():判断生产者组名不能是空,也不能是 default_PRODUCER

    • if (!getProducerGroup().equals(MixAll.CLIENT_INNER_PRODUCER_GROUP)):条件成立说明当前生产者不是内部产生者,内部生产者是处理消息回退的这种情况使用的生产者

      this.defaultMQProducer.changeInstanceNameToPID():修改生产者实例名称为当前进程的 PID

    • this.mQClientFactory = ...:获取当前进程的 MQ 客户端实例对象,从 factoryTable 中获取 key 为 客户端 ID,格式是ip@pid一个 JVM 进程只有一个 PID,也只有一个 MQClientInstance

    • boolean registerOK = mQClientFactory.registerProducer(...):将生产者注册到 RocketMQ 客户端实例内

    • this.topicPublishInfoTable.put(...):添加一个主题发布信息,key 是 TBW102 ,value 是一个空对象

    • mQClientFactory.start():启动 RocketMQ 客户端实例对象

    • this.mQClientFactory.sendHeartbeatToAllBrokerWithLock():RocketMQ 客户端实例向已知的 Broker 节点发送一次心跳(也是定时任务)

    • this.timer.scheduleAtFixedRate(): request 发送的消息需要消费着回执信息,启动定时任务每秒一次删除超时请求

      • 生产者 msg 添加信息关联 ID 发送到 Broker
      • 消费者从 Broker 拿到消息后会检查 msg 类型是一个需要回执的消息,处理完消息后会根据 msg 关联 ID 和客户端 ID 生成一条响应结果消息发送到 Broker,Broker 判断为回执消息,会根据客户端ID 找到 channel 推送给生产者
      • 生产者拿到回执消息后,读取出来关联 ID 找到对应的 RequestFuture,将阻塞线程唤醒
  • sendDefaultImpl():发送消息

    1
    2
    //参数1:消息;参数2:发送模式(同步异步单向);参数3:回调函数,异步发送时需要;参数4:发送超时时间, 默认 3 秒
    private SendResult sendDefaultImpl(msg, communicationMode, sendCallback,timeout) {}
    • this.makeSureStateOK():校验生产者状态是运行中,否则抛出异常

    • topicPublishInfo = this.tryToFindTopicPublishInfo(msg.getTopic())获取当前消息主题的发布信息

      • this.topicPublishInfoTable.get(topic):先尝试从本地主题发布信息映射表获取信息,获取不到继续执行

      • this.mQClientFactory.update...FromNameServer(topic):然后从 Namesrv 更新该 Topic 的路由数据

      • this.mQClientFactory.update...FromNameServer(...)路由数据是空,获取默认 TBW102 的数据

        return topicPublishInfo:返回 TBW102 主题的发布信息

    • String[] brokersSent = new String[timesTotal]:下标索引代表第几次发送,值代表这次发送选择 Broker name

    • for (; times < timesTotal; times++):循环发送,发送成功或者发送尝试次数达到上限,结束循环

    • String lastBrokerName = null == mq ? null : mq.getBrokerName():获取上次发送失败的 BrokerName

    • mqSelected = this.selectOneMessageQueue(topicPublishInfo, lastBrokerName):从发布信息中选择一个队列,生产者的负载均衡策略,参考系统特性章节

    • brokersSent[times] = mq.getBrokerName():将本次选择的 BrokerName 存入数组

    • msg.setTopic(this.defaultMQProducer.withNamespace(msg.getTopic()))产生重投,重投消息需要加上标记

    • sendResult = this.sendKernelImpl:核心发送方法

    • switch (communicationMode):异步或者单向消息直接返回 null,异步通过回调函数处理,同步发送进入逻辑判断

      if (sendResult.getSendStatus() != SendStatus.SEND_OK)服务端 Broker 存储失败,需要重试其他 Broker

    • throw new MQClientException():未找到当前主题的路由数据,无法发送消息,抛出异常

  • sendKernelImpl():核心发送方法

    1
    2
    //参数1:消息;参数2:选择的队列;参数3:发送模式(同步异步单向);参数4:回调函数,异步发送时需要;参数5:主题发布信息;参数6:剩余超时时间限制
    private SendResult sendKernelImpl(msg, mq, communicationMode, sendCallback, topicPublishInfo, timeout)
    • brokerAddr = this.mQClientFactory(...)获取指定 BrokerName 对应的 mater 节点的地址,master 节点的 ID 为 0,集群模式下,发送消息要发到主节点

    • brokerAddr = MixAll.brokerVIPChannel():Broker 启动时会绑定两个服务器端口,一个是普通端口,一个是 VIP 端口,服务器端根据不同端口创建不同的的 NioSocketChannel

    • byte[] prevBody = msg.getBody():获取消息体

    • if (!(msg instanceof MessageBatch)):非批量消息,需要重新设置消息 ID

      MessageClientIDSetter.setUniqID(msg)msg id 由两部分组成,一部分是 ip 地址、进程号、Classloader 的 hashcode,另一部分是时间差(当前时间减去当月一号的时间)和计数器的值

    • if (this.tryToCompressMessage(msg)):判断消息是否压缩,压缩需要设置压缩标记

    • hasCheckForbiddenHook、hasSendMessageHook:执行钩子方法

    • requestHeader = new SendMessageRequestHeader():设置发送消息的消息头

    • if (requestHeader.getTopic().startsWith(MixAll.RETRY_GROUP_TOPIC_PREFIX)):重投的发送消息

    • switch (communicationMode):异步发送一种处理方式,单向和同步同样的处理逻辑

      sendResult = this.mQClientFactory.getMQClientAPIImpl().sendMessage()发送消息

      • request = RemotingCommand.createRequestCommand():创建一个 RequestCommand 对象
      • request.setBody(msg.getBody())将消息放入请求体
      • switch (communicationMode)根据不同的模式 invoke 不同的方法
  • request():请求方法,消费者回执消息,这种消息是异步消息

    • requestResponseFuture = new RequestResponseFuture(correlationId, timeout, null):创建请求响应对象

    • getRequestFutureTable().put(correlationId, requestResponseFuture):放入RequestFutureTable 映射表中

    • this.sendDefaultImpl(msg, CommunicationMode.ASYNC, new SendCallback())发送异步消息,有回调函数

    • return waitResponse(msg, timeout, requestResponseFuture, cost):用来挂起请求的方法

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
        public Message waitResponseMessage(final long timeout) throws InterruptedException {
      // 请求挂起
      this.countDownLatch.await(timeout, TimeUnit.MILLISECONDS);
      return this.responseMsg;
      }

      * 当消息被消费后,客户端处理响应时通过消息的关联 ID,从映射表中获取消息的 RequestResponseFuture,执行下面的方法唤醒挂起线程

      ```java
      public void putResponseMessage(final Message responseMsg) {
      this.responseMsg = responseMsg;
      this.countDownLatch.countDown();
      }

路由信息

TopicPublishInfo 类用来存储路由信息

成员变量:

  • 顺序消息:

    1
    private boolean orderTopic = false;
  • 消息队列:

    1
    2
    private List<MessageQueue> messageQueueList = new ArrayList<>();			// 主题全部的消息队列
    private volatile ThreadLocalIndex sendWhichQueue = new ThreadLocalIndex(); // 消息队列索引
    1
    2
    3
    4
    5
    6
    // 【消息队列类】
    public class MessageQueue implements Comparable<MessageQueue>, Serializable {
    private String topic;
    private String brokerName;
    private int queueId;// 队列 ID
    }
  • 路由数据:主题对应的路由数据

    1
    private TopicRouteData topicRouteData;
    1
    2
    3
    4
    5
    6
    public class TopicRouteData extends RemotingSerializable {
    private String orderTopicConf;
    private List<QueueData> queueDatas; // 队列数据
    private List<BrokerData> brokerDatas; // Broker 数据
    private HashMap<String/* brokerAddr */, List<String>/* Filter Server */> filterServerTable;
    }
    1
    2
    3
    4
    5
    6
    7
    public class QueueData implements Comparable<QueueData> {
    private String brokerName; // 节点名称
    private int readQueueNums; // 读队列数
    private int writeQueueNums; // 写队列数
    private int perm; // 权限
    private int topicSynFlag;
    }
    1
    2
    3
    4
    5
    public class BrokerData implements Comparable<BrokerData> {
    private String cluster; // 集群名
    private String brokerName; // Broker节点名称
    private HashMap<Long/* brokerId */, String/* broker address */> brokerAddrs;
    }

核心方法:

  • selectOneMessageQueue():选择消息队列使用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    // 参数是上次失败时的 brokerName,可以为 null
    public MessageQueue selectOneMessageQueue(final String lastBrokerName) {
    if (lastBrokerName == null) {
    return selectOneMessageQueue();
    } else {
    // 遍历消息队列
    for (int i = 0; i < this.messageQueueList.size(); i++) {
    // 【获取队列的索引,+1】
    int index = this.sendWhichQueue.getAndIncrement();
    // 获取队列的下标位置
    int pos = Math.abs(index) % this.messageQueueList.size();
    if (pos < 0)
    pos = 0;
    // 获取消息队列
    MessageQueue mq = this.messageQueueList.get(pos);
    // 与上次选择的不同就可以返回
    if (!mq.getBrokerName().equals(lastBrokerName)) {
    return mq;
    }
    }
    return selectOneMessageQueue();
    }
    }

公共配置

公共的配置信息类

  • ClientConfig 类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    public class ClientConfig {
    // Namesrv 地址配置
    private String namesrvAddr = NameServerAddressUtils.getNameServerAddresses();
    // 客户端的 IP 地址
    private String clientIP = RemotingUtil.getLocalAddress();
    // 客户端实例名称
    private String instanceName = System.getProperty("rocketmq.client.name", "DEFAULT");
    // 客户端回调线程池的数量,平台核心数,8核16线程的电脑返回16
    private int clientCallbackExecutorThreads = Runtime.getRuntime().availableProcessors();
    // 命名空间
    protected String namespace;
    protected AccessChannel accessChannel = AccessChannel.LOCAL;

    // 获取路由信息的间隔时间 30s
    private int pollNameServerInterval = 1000 * 30;
    // 客户端与 broker 之间的心跳周期 30s
    private int heartbeatBrokerInterval = 1000 * 30;
    // 消费者持久化消费的周期 5s
    private int persistConsumerOffsetInterval = 1000 * 5;
    private long pullTimeDelayMillsWhenException = 1000;
    private boolean unitMode = false;
    private String unitName;
    // vip 通道,broker 启动时绑定两个端口,其中一个是 vip 通道
    private boolean vipChannelEnabled = Boolean.parseBoolean();
    // 语言,默认是 Java
    private LanguageCode language = LanguageCode.JAVA;
    }
  • NettyClientConfig

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    public class NettyClientConfig {
    // 客户端工作线程数
    private int clientWorkerThreads = 4;
    // 回调处理线程池 线程数:平台核心数
    private int clientCallbackExecutorThreads = Runtime.getRuntime().availableProcessors();
    // 单向请求并发数,默认 65535
    private int clientOnewaySemaphoreValue = NettySystemConfig.CLIENT_ONEWAY_SEMAPHORE_VALUE;
    // 异步请求并发数,默认 65535
    private int clientAsyncSemaphoreValue = NettySystemConfig.CLIENT_ASYNC_SEMAPHORE_VALUE;
    // 客户端连接服务器的超时时间限制 3秒
    private int connectTimeoutMillis = 3000;
    // 客户端未激活周期,60s(指定时间内 ch 未激活,需要关闭)
    private long channelNotActiveInterval = 1000 * 60;
    // 客户端与服务器 ch 最大空闲时间 2分钟
    private int clientChannelMaxIdleTimeSeconds = 120;

    // 底层 Socket 写和收 缓冲区的大小 65535 64k
    private int clientSocketSndBufSize = NettySystemConfig.socketSndbufSize;
    private int clientSocketRcvBufSize = NettySystemConfig.socketRcvbufSize;
    // 客户端 netty 是否启动内存池
    private boolean clientPooledByteBufAllocatorEnable = false;
    // 客户端是否超时关闭 Socket 连接
    private boolean clientCloseSocketIfTimeout = false;
    }

客户端类

成员属性

MQClientInstance 是 RocketMQ 客户端实例,在一个 JVM 进程中只有一个客户端实例,既服务于生产者,也服务于消费者

成员变量:

  • 配置信息:

    1
    2
    3
    4
    private final int instanceIndex;			// 索引一般是 0,因为客户端实例一般都是一个进程只有一个
    private final String clientId; // 客户端 ID ip@pid
    private final long bootTimestamp; // 客户端的启动时间
    private ServiceState serviceState; // 客户端状态
  • 生产者消费者的映射表:key 是组名

    1
    2
    3
    private final ConcurrentMap<String, MQProducerInner> producerTable
    private final ConcurrentMap<String, MQConsumerInner> consumerTable
    private final ConcurrentMap<String, MQAdminExtInner> adminExtTable
  • 网络层配置:

    1
    private final NettyClientConfig nettyClientConfig;
  • 核心功能的实现:负责将 MQ 业务层的数据转换为网络层的 RemotingCommand 对象,使用内部持有的 NettyRemotingClient 对象的 invoke 系列方法,完成网络 IO(同步、异步、单向)

    1
    private final MQClientAPIImpl mQClientAPIImpl;
  • 本地路由数据:key 是主题名称,value 路由信息

    1
    private final ConcurrentMap<String, TopicRouteData> topicRouteTable = new ConcurrentHashMap<>();
  • 锁信息:两把锁,锁不同的数据

    1
    2
    private final Lock lockNamesrv = new ReentrantLock();
    private final Lock lockHeartbeat = new ReentrantLock();
  • 调度线程池:单线程,执行定时任务

    1
    private final ScheduledExecutorService scheduledExecutorService;
  • Broker 映射表:key 是 BrokerName

    1
    2
    3
    4
    // 物理节点映射表,value:Long 是 brokerID,【ID=0 的是主节点,其他是从节点】,String 是地址 ip:port
    private final ConcurrentMap<String, HashMap<Long, String>> brokerAddrTable;
    // 物理节点版本映射表,String 是地址 ip:port,Integer 是版本
    ConcurrentMap<String, HashMap<String, Integer>> brokerVersionTable;
  • 客户端的协议处理器:用于处理 IO 事件

    1
    private final ClientRemotingProcessor clientRemotingProcessor;
  • 消息服务:

    1
    2
    3
    private final PullMessageService pullMessageService;		// 拉消息服务
    private final RebalanceService rebalanceService; // 消费者负载均衡服务
    private final ConsumerStatsManager consumerStatsManager; // 消费者状态管理
  • 内部生产者实例:处理消费端消息回退,用该生产者发送回退消息

    1
    private final DefaultMQProducer defaultMQProducer;
  • 心跳次数统计:

    1
    private final AtomicLong sendHeartbeatTimesTotal = new AtomicLong(0)

构造方法:

  • MQClientInstance 有参构造:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    public MQClientInstance(ClientConfig clientConfig, int instanceIndex, String clientId, RPCHook rpcHook) {
    this.clientConfig = clientConfig;
    this.instanceIndex = instanceIndex;
    // Netty 相关的配置信息
    this.nettyClientConfig = new NettyClientConfig();
    // 平台核心数
    this.nettyClientConfig.setClientCallbackExecutorThreads(...);
    this.nettyClientConfig.setUseTLS(clientConfig.isUseTLS());
    // 【创建客户端协议处理器】
    this.clientRemotingProcessor = new ClientRemotingProcessor(this);
    // 创建 API 实现对象
    // 参数一:客户端网络配置
    // 参数二:客户端协议处理器,注册到客户端网络层
    // 参数三:rpcHook,注册到客户端网络层
    // 参数四:客户端配置
    this.mQClientAPIImpl = new MQClientAPIImpl(this.nettyClientConfig, this.clientRemotingProcessor, rpcHook, clientConfig);

    //...
    // 内部生产者,指定内部生产者的组
    this.defaultMQProducer = new DefaultMQProducer(MixAll.CLIENT_INNER_PRODUCER_GROUP);
    }
  • MQClientAPIImpl 有参构造:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public MQClientAPIImpl(nettyClientConfig, clientRemotingProcessor, rpcHook, clientConfig) {
    this.clientConfig = clientConfig;
    topAddressing = new TopAddressing(MixAll.getWSAddr(), clientConfig.getUnitName());
    // 创建网络层对象,参数二为 null 说明客户端并不关心 channel event
    this.remotingClient = new NettyRemotingClient(nettyClientConfig, null);
    // 业务处理器
    this.clientRemotingProcessor = clientRemotingProcessor;
    // 注册 RpcHook
    this.remotingClient.registerRPCHook(rpcHook);
    // ...
    // 注册回退消息的请求码
    this.remotingClient.registerProcessor(RequestCode.PUSH_REPLY_MESSAGE_TO_CLIENT, this.clientRemotingProcessor, null);
    }

成员方法
  • start():启动方法

    • synchronized (this):加锁保证线程安全,保证只有一个实例对象启动
    • this.mQClientAPIImpl.start():启动客户端网络层,底层调用 RemotingClient 类
    • this.startScheduledTask():启动定时任务
    • this.pullMessageService.start():启动拉取消息服务
    • this.rebalanceService.start():启动负载均衡服务
    • this.defaultMQProducer...start(false):启动内部生产者,参数为 false 代表不启动实例
  • startScheduledTask():启动定时任务,调度线程池是单线程

    • if (null == this.clientConfig.getNamesrvAddr()):Namesrv 地址是空,需要两分钟拉取一次 Namesrv 地址

    • 定时任务 1:从 Namesrv 更新客户端本地的路由数据,周期 30 秒一次

      1
      2
      // 获取生产者和消费者订阅的主题集合,遍历集合,对比从 namesrv 拉取最新的主题路由数据和本地数据,是否需要更新
      MQClientInstance.this.updateTopicRouteInfoFromNameServer();
    • 定时任务 2:周期 30 秒一次,两个任务

      • 清理下线的 Broker 节点,遍历客户端的 Broker 物理节点映射表,将所有主题数据都不包含的 Broker 物理节点清理掉,如果被清理的 Broker 下所有的物理节点都没有了,就将该 Broker 的映射数据删除掉
      • 向在线的所有的 Broker 发送心跳数据,同步发送的方式,返回值是 Broker 物理节点的版本号,更新版本映射表
      1
      2
      MQClientInstance.this.cleanOfflineBroker();
      MQClientInstance.this.sendHeartbeatToAllBrokerWithLock();
      1
      2
      3
      4
      5
      6
      7
      8
      9
      // 心跳数据
      public class HeartbeatData extends RemotingSerializable {
      // 客户端 ID ip@pid
      private String clientID;
      // 存储客户端所有生产者数据
      private Set<ProducerData> producerDataSet = new HashSet<ProducerData>();
      // 存储客户端所有消费者数据
      private Set<ConsumerData> consumerDataSet = new HashSet<ConsumerData>();
      }
    • 定时任务 3:消费者持久化消费数据,周期 5 秒一次

      1
      MQClientInstance.this.persistAllConsumerOffset();
    • 定时任务 4:动态调整消费者线程池,周期 1 分钟一次

      1
      MQClientInstance.this.adjustThreadPool();
  • updateTopicRouteInfoFromNameServer():更新路由数据,通过加锁保证当前实例只有一个线程去更新

    • if (isDefault && defaultMQProducer != null):需要默认数据

      topicRouteData = ...getDefaultTopicRouteInfoFromNameServer():从 Namesrv 获取默认的 TBW102 的路由数据

    • topicRouteData = ...getTopicRouteInfoFromNameServer(topic):需要从 Namesrv 获取路由数据(同步)

    • old = this.topicRouteTable.get(topic):获取客户端实例本地的该主题的路由数据

    • boolean changed = topicRouteDataIsChange(old, topicRouteData):对比本地和最新下拉的数据是否一致

    • if (changed):不一致进入更新逻辑

      this.brokerAddrTable.put(...):更新客户端 broker 物理节点映射表

      Update Pub info:更新生产者信息

      • publishInfo = topicRouteData2TopicPublishInfo(topic, topicRouteData):将主题路由数据转化为发布数据,会创建消息队列 MQ,放入发布数据对象的集合中
      • impl.updateTopicPublishInfo(topic, publishInfo):生产者将主题的发布数据保存到它本地,方便发送消息使用

      Update sub info:更新消费者信息,创建 MQ 队列,更新订阅信息,用于负载均衡

      this.topicRouteTable.put(topic, cloneTopicRouteData)将数据放入本地路由表


网络通信

成员属性

NettyRemotingClient 类负责客户端的网络通信

成员变量:

  • Netty 服务相关属性:

    1
    2
    3
    private final NettyClientConfig nettyClientConfig;			// 客户端的网络层配置
    private final Bootstrap bootstrap = new Bootstrap(); // 客户端网络层启动对象
    private final EventLoopGroup eventLoopGroupWorker; // 客户端网络层 Netty IO 线程组
  • Channel 映射表:

    1
    2
    private final ConcurrentMap<String, ChannelWrapper> channelTables;// key 是服务器的地址,value 是通道对象
    private final Lock lockChannelTables = new ReentrantLock(); // 锁,控制并发安全
  • 定时器:启动定时任务

    1
    private final Timer timer = new Timer("ClientHouseKeepingService", true)
  • 线程池:

    1
    2
    private ExecutorService publicExecutor;		// 公共线程池
    private ExecutorService callbackExecutor; // 回调线程池,客户端发起异步请求,服务器的响应数据由回调线程池处理
  • 事件监听器:客户端这里是 null

    1
    private final ChannelEventListener channelEventListener;

构造方法

  • 无参构造:

    1
    2
    3
    public NettyRemotingClient(final NettyClientConfig nettyClientConfig) {
    this(nettyClientConfig, null);
    }
  • 有参构造:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    public NettyRemotingClient(nettyClientConfig, channelEventListener) {
    // 父类创建了2个信号量,1、控制单向请求的并发度,2、控制异步请求的并发度
    super(nettyClientConfig.getClientOnewaySemaphoreValue(), nettyClientConfig.getClientAsyncSemaphoreValue());
    this.nettyClientConfig = nettyClientConfig;
    this.channelEventListener = channelEventListener;

    // 创建公共线程池
    int publicThreadNums = nettyClientConfig.getClientCallbackExecutorThreads();
    if (publicThreadNums <= 0) {
    publicThreadNums = 4;
    }
    this.publicExecutor = Executors.newFixedThreadPool(publicThreadNums,);

    // 创建 Netty IO 线程,1个线程
    this.eventLoopGroupWorker = new NioEventLoopGroup(1, );

    if (nettyClientConfig.isUseTLS()) {
    sslContext = TlsHelper.buildSslContext(true);
    }
    }

成员方法
  • start():启动方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    public void start() {
    // channel pipeline 内的 handler 使用的线程资源,默认 4 个
    this.defaultEventExecutorGroup = new DefaultEventExecutorGroup();
    // 配置 netty 客户端启动类对象
    Bootstrap handler = this.bootstrap.group(this.eventLoopGroupWorker).channel(NioSocketChannel.class)
    //...
    .handler(new ChannelInitializer<SocketChannel>() {
    @Override
    public void initChannel(SocketChannel ch) throws Exception {
    ChannelPipeline pipeline = ch.pipeline();
    // 加几个handler
    pipeline.addLast(
    // 服务端的数据,都会来到这个
    new NettyClientHandler());
    }
    });
    // 注意 Bootstrap 只是配置好客户端的元数据了,【在这里并没有创建任何 channel 对象】
    // 定时任务 扫描 responseTable 中超时的 ResponseFuture,避免客户端线程长时间阻塞
    this.timer.scheduleAtFixedRate(() -> {
    NettyRemotingClient.this.scanResponseTable();
    }, 1000 * 3, 1000);
    // 这里是 null,不启动
    if (this.channelEventListener != null) {
    this.nettyEventExecutor.start();
    }
    }
  • 单向通信:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    public RemotingCommand invokeSync(String addr, final RemotingCommand request, long timeoutMillis) {
    // 开始时间
    long beginStartTime = System.currentTimeMillis();
    // 获取或者创建客户端与服务端(addr)的通道 channel
    final Channel channel = this.getAndCreateChannel(addr);
    // 条件成立说明客户端与服务端 channel 通道正常,可以通信
    if (channel != null && channel.isActive()) {
    try {
    // 执行 rpcHook 拓展点
    doBeforeRpcHooks(addr, request);
    // 计算耗时,如果当前耗时已经超过 timeoutMillis 限制,则直接抛出异常,不再进行系统通信
    long costTime = System.currentTimeMillis() - beginStartTime;
    if (timeoutMillis < costTime) {
    throw new RemotingTimeoutException("invokeSync call timeout");
    }
    // 参数1:客户端-服务端通道channel
    // 参数二:网络层传输对象,封装着请求数据
    // 参数三:剩余的超时限制
    RemotingCommand response = this.invokeSyncImpl(channel, request, ...);
    // 后置处理
    doAfterRpcHooks(RemotingHelper.parseChannelRemoteAddr(channel), request, response);
    // 返回响应数据
    return response;
    } catch (RemotingSendRequestException e) {}
    } else {
    this.closeChannel(addr, channel);
    throw new RemotingConnectException(addr);
    }
    }

延迟消息

消息处理

BrokerStartup 初始化 BrokerController 调用 registerProcessor() 方法将 SendMessageProcessor 注册到 NettyRemotingServer 中,对应的请求 ID 为 SEND_MESSAGE = 10,NettyServerHandler 在处理请求时通过 CMD 会获取处理器执行 processRequest

1
2
3
4
5
6
// 参数一:处理通道的事件;   参数二:客户端
public RemotingCommand processRequest(ChannelHandlerContext ctx, RemotingCommand request) {
RemotingCommand response = null;
response = asyncProcessRequest(ctx, request).get();
return response;
}

SendMessageProcessor#asyncConsumerSendMsgBack:异步发送消费者的回调消息

  • final RemotingCommand response:创建一个服务器响应对象

  • final ConsumerSendMsgBackRequestHeader requestHeader:解析出客户端请求头信息,几个核心字段

    • private Long offset:回退消息的 CommitLog offset
    • private Integer delayLevel:延迟级别,一般是 0
    • private String originMsgId, originTopic:原始的消息 ID,主题
    • private Integer maxReconsumeTimes:最大重试次数,默认是 16 次
  • if ():鉴权,是否找到订阅组配置、Broker 是否支持写请求、订阅组是否支持消息重试

  • String newTopic = MixAll.getRetryTopic(...)获取消费者组的重试主题,规则是 %RETRY%GroupName

  • int queueIdInt = Math.abs()重试主题下的队列 ID 是 0

  • TopicConfig topicConfig:获取重试主题的配置信息

  • MessageExt msgExt:根据消息的物理 offset 到存储模块查询,内部先查询出这条消息的 size,然后再根据 offset 和 size 查询出整条 msg

  • final String retryTopic:获取消息的原始主题

  • if (null == retryTopic):条件成立说明当前消息是第一次被回退, 添加 RETRY_TOPIC 属性

  • msgExt.setWaitStoreMsgOK(false):异步刷盘

  • if (msgExt...() >= maxReconsumeTimes || delayLevel < 0):消息重试次数超过最大次数,不支持重试

    newTopic = MixAll.getDLQTopic()获取消费者的死信队列,规则是 %DLQ%GroupName

    queueIdInt, topicConfig:死信队列 ID 为 0,创建死信队列的配置

  • if (0 == delayLevel):说明延迟级别由 Broker 控制

    delayLevel = 3 + msgExt.getReconsumeTimes()延迟级别默认从 3 级开始,每重试一次,延迟级别 +1

  • msgExt.setDelayTimeLevel(delayLevel)将延迟级别设置进消息属性,存储时会检查该属性,该属性值 > 0 会将消息的主题和队列修改为调度主题和调度队列 ID

  • MessageExtBrokerInner msgInner:创建一条空消息,消息属性从 offset 查询出来的 msg 中拷贝

  • msgInner.setReconsumeTimes):重试次数设置为原 msg 的次数 +1

  • UtilAll.isBlank(originMsgId):判断消息是否是初次返回到服务器

    • true:说明 msgExt 消息是第一次被返回到服务器,此时使用该 msg 的 id 作为 originMessageId
    • false:说明原始消息已经被重试不止 1 次,此时使用 offset 查询出来的 msg 中的 originMessageId
  • CompletableFuture putMessageResult = ..asyncPutMessage(msgInner):调用存储模块存储消息

    DefaultMessageStore#asyncPutMessage

    • PutMessageResult result = this.commitLog.asyncPutMessage(msg)将新消息存储到 CommitLog 中

调度服务

DefaultMessageStore 中有成员属性 ScheduleMessageService,在 start 方法中会启动该调度服务

成员变量:

  • 延迟级别属性表:

    1
    2
    3
    4
    // 存储延迟级别对应的 延迟时间长度 (单位:毫秒)
    private final ConcurrentMap<Integer /* level */, Long/* delay timeMillis */> delayLevelTable;
    // 存储延迟级别 queue 的消费进度 offset,该 table 每 10 秒钟,会持久化一次,持久化到本地磁盘
    private final ConcurrentMap<Integer /* level */, Long/* offset */> offsetTable;
  • 最大延迟级别:

    1
    private int maxDelayLevel;
  • 模块启动状态:

    1
    private final AtomicBoolean started = new AtomicBoolean(false);
  • 定时器:内部有线程资源,可执行调度任务

    1
    private Timer timer;

成员方法:

  • load():加载调度消息,初始化 delayLevelTable 和 offsetTable

    1
    public boolean load()
  • start():启动消息调度服务

    1
    public void start()
    • if (started.compareAndSet(false, true)):将启动状态设为 true

    • this.timer:创建定时器对象

    • for (... : this.delayLevelTable.entrySet()):为每个延迟级别创建一个延迟任务提交到 timer ,周期执行,这样就可以将延迟消息得到及时的消费

    • this.timer.scheduleAtFixedRate():提交周期型任务,延迟 10 秒执行,周期为 10 秒,持久化延迟队列消费进度任务

      ScheduleMessageService.this.persist():持久化消费进度


调度任务

DeliverDelayedMessageTimerTask 是一个任务类

成员变量:

  • 延迟级别:延迟队列任务处理的延迟级别

    1
    private final int delayLevel;
  • 消费进度:延迟队列任务处理的延迟队列的消费进度

    1
    private final long offset;

成员方法:

  • run():执行任务

    1
    2
    3
    4
    public void run() {
    if (isStarted()) {
    this.executeOnTimeup();
    }
  • executeOnTimeup():执行任务

    1
    public void executeOnTimeup()
    • ConsumeQueue cq:获取出该延迟队列任务处理的延迟队列 ConsumeQueue

    • SelectMappedBufferResult bufferCQ:根据消费进度查询出 SMBR 对象

    • for (; i < bufferCQ.getSize(); i += ConsumeQueue.CQ_STORE_UNIT_SIZE):每次读取 20 各字节的数据

    • offsetPy, sizePy:延迟消息的物理偏移量和消息大小

    • long tagsCode:延迟消息的交付时间,在 ReputMessageService 转发时根据消息的 DELAY 属性是否 >0 ,会在 tagsCode 字段存储交付时间

    • long deliver... = this.correctDeliverTimestamp(..)校准交付时间,延迟时间过长会调整为当前时间立刻执行

    • long countdown = deliverTimestamp - now:计算差值

    • if (countdown <= 0)消息已经到达交付时间了

      MessageExt msgExt:根据物理偏移量和消息大小获取这条消息

      MessageExtBrokerInner msgInner构建一条新消息,将原消息的属性拷贝过来

      • long tagsCodeValue:不再是交付时间了
      • MessageAccessor.clearProperty(msgInner, DELAY..):清理新消息的 DELAY 属性,避免存储时重定向到延迟队列
      • msgInner.setTopic()修改主题为原始的主题 %RETRY%GroupName
      • String queueIdStr:修改队列 ID 为原始的 ID

      PutMessageResult putMessageResult将新消息存储到 CommitLog,消费者订阅的是目标主题,会再次消费该消息

    • else:消息还未到达交付时间

      ScheduleMessageService.this.timer.schedule():创建该延迟级别的任务,延迟 countDown 毫秒之后再执行

      ScheduleMessageService.this.updateOffset():更新延迟级别队列的消费进度

    • PutMessageResult putMessageResult

    • bufferCQ == null:说明通过消费进度没有获取到数据

      if (offset < cqMinOffset):如果消费进度比最小位点都小,说明是过期数据,重置为最小位点

    • ScheduleMessageService.this.timer.schedule():重新提交该延迟级别对应的延迟队列任务,延迟 100 毫秒后执行


事务消息

生产者类

TransactionMQProducer 类发送事务消息时使用

成员变量:

  • 事务回查线程池资源:

    1
    2
    3
    4
    5
    6
      private ExecutorService executorService;

    * 事务监听器:

    ```java
    private TransactionListener transactionListener;

核心方法:

  • start():启动方法

    1
    public void start()
    • this.defaultMQProducerImpl.initTransactionEnv():初始化生产者实例和回查线程池资源
    • super.start():启动生产者实例
  • sendMessageInTransaction():发送事务消息

    1
    2
    3
    4
    5
    public TransactionSendResult sendMessageInTransaction(final Message msg, final Object arg) {
    msg.setTopic(NamespaceUtil.wrapNamespace(this.getNamespace(), msg.getTopic()));
    // 调用实现类的发送方法
    return this.defaultMQProducerImpl.sendMessageInTransaction(msg, null, arg);
    }
    • TransactionListener transactionListener = getCheckListener():获取监听器

    • if (null == localTransactionExecuter && null == transactionListener):两者都为 null 抛出异常

    • MessageAccessor.putProperty(msg, MessageConst.PROPERTY_TRANSACTION_PREPARED, "true")设置事务标志

    • sendResult = this.send(msg):发送消息,同步发送

    • switch (sendResult.getSendStatus())判断发送消息的结果状态

    • case SEND_OK:消息发送成功

      msg.setTransactionId(transactionId)设置事务 ID 为消息的 UNIQ_KEY 属性

      localTransactionState = ...executeLocalTransactionBranch(msg, arg)执行本地事务

    • case SLAVE_NOT_AVAILABLE:其他情况都需要回滚事务

      localTransactionState = LocalTransactionState.ROLLBACK_MESSAGE事务状态设置为回滚

    • this.endTransaction(sendResult, ...):结束事务

      • EndTransactionRequestHeader requestHeader:构建事务结束头对象
      • this.mQClientFactory.getMQClientAPIImpl().endTransactionOneway():向 Broker 发起事务结束的单向请求

接受消息

SendMessageProcessor 是服务端处理客户端发送来的消息的处理器,processRequest() 方法处理请求

核心方法:

  • asyncProcessRequest():处理请求

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    public CompletableFuture<RemotingCommand> asyncProcessRequest(ChannelHandlerContext ctx,
    RemotingCommand request) {
    final SendMessageContext mqtraceContext;
    switch (request.getCode()) {
    // 回调消息回退
    case RequestCode.CONSUMER_SEND_MSG_BACK:
    return this.asyncConsumerSendMsgBack(ctx, request);
    default:
    // 解析出请求头对象
    SendMessageRequestHeader requestHeader = parseRequestHeader(request);
    if (requestHeader == null) {
    return CompletableFuture.completedFuture(null);
    }
    // 创建上下文对象
    mqtraceContext = buildMsgContext(ctx, requestHeader);
    // 前置处理器
    this.executeSendMessageHookBefore(ctx, request, mqtraceContext);
    // 判断是否是批量消息
    if (requestHeader.isBatch()) {
    return this.asyncSendBatchMessage(ctx, request, mqtraceContext, requestHeader);
    } else {
    return this.asyncSendMessage(ctx, request, mqtraceContext, requestHeader);
    }
    }
    }
  • asyncSendMessage():异步处理发送消息

    1
    private CompletableFuture<RemotingCommand> asyncSendMessage(ChannelHandlerContext ctx, RemotingCommand request, SendMessageContext mqtraceContext, SendMessageRequestHeader requestHeader)
    • RemotingCommand response:创建响应对象

    • MessageExtBrokerInner msgInner = new MessageExtBrokerInner():创建 msgInner 对象,并赋值相关的属性,主题和队列 ID 都是请求头中的

    • String transFlag获取事务属性

    • if (transFlag != null && Boolean.parseBoolean(transFlag)):判断事务属性是否是 true,走事务消息的存储流程

      • putMessageResult = ...asyncPrepareMessage(msgInner)事务消息处理流程

        1
        2
        3
        4
        public CompletableFuture<PutMessageResult> asyncPutHalfMessage(MessageExtBrokerInner messageInner) {
        // 调用存储模块,将修改后的 msg 存储进 Broker(CommitLog)
        return store.asyncPutMessage(parseHalfMessageInner(messageInner));
        }

        TransactionalMessageBridge#parseHalfMessageInner:

        • MessageAccessor.putProperty(...)将消息的原主题和队列 ID 放入消息的属性中
        • msgInner.setSysFlag(...):消息设置为非事务状态
        • msgInner.setTopic(TransactionalMessageUtil.buildHalfTopic())消息主题设置为半消息主题
        • msgInner.setQueueId(0)队列 ID 设置为 0
    • else:普通消息存储


回查处理

ClientRemotingProcessor 是客户端用于处理请求,创建 MQClientAPIImpl 时将该处理器注册到 Netty 中,processRequest() 方法根据请求的命令码,进行不同的处理,事务回查的处理命令码为 CHECK_TRANSACTION_STATE

Broker 端有定时任务发送回查请求

成员方法:

  • checkTransactionState():检查事务状态

    1
    public RemotingCommand checkTransactionState(ChannelHandlerContext ctx, RemotingCommand request)
    • final CheckTransactionStateRequestHeader requestHeader:解析出请求头对象
    • final MessageExt messageExt:从请求 body 中解析出服务器回查的事务消息
    • String transactionId:提取 UNIQ_KEY 字段属性值赋值给事务 ID
    • final String group:提取生产者组名
    • MQProducerInner producer = this...selectProducer(group):根据生产者组获取生产者对象
    • String addr = RemotingHelper.parseChannelRemoteAddr():解析出要回查的 Broker 服务器的地址
    • producer.checkTransactionState(addr, messageExt, requestHeader):生产者的事务回查
      • Runnable request = new Runnable()创建回查事务状态任务对象
        • 获取生产者的 TransactionCheckListener 和 TransactionListener,选择一个不为 null 的监听器进行事务状态回查
        • this.processTransactionState():处理回查状态
          • EndTransactionRequestHeader thisHeader:构建 EndTransactionRequestHeader 对象
          • DefaultMQProducerImpl...endTransactionOneway():向 Broker 发起结束事务单向请求,二阶段提交
      • this.checkExecutor.submit(request):提交到线程池运行

参考图:https://www.processon.com/view/link/61c8257e0e3e7474fb9dcbc0

参考视频:https://space.bilibili.com/457326371


事务提交

EndTransactionProcessor 类是服务端用来处理客户端发来的提交或者回滚请求

  • processRequest():处理请求

    1
    public RemotingCommand processRequest(ChannelHandlerContext ctx, RemotingCommand request)
    • EndTransactionRequestHeader requestHeader:从请求中解析出 EndTransactionRequestHeader

    • if (MessageSysFlag.TRANSACTION_COMMIT_TYPE)事务提交

      result = this.brokerController...commitMessage(requestHeader):根据 commitLogOffset 提取出 halfMsg 消息

      MessageExtBrokerInner msgInner:根据 result 克隆出一条新消息

      • msgInner.setTopic(msgExt.getUserProperty(...))设置回原主题

      • msgInner.setQueueId(Integer.parseInt(msgExt.getUserProperty(..)))设置回原队列 ID

      • MessageAccessor.clearProperty():清理上面的两个属性

      MessageAccessor.clearProperty(msgInner, ...)清理事务属性

      RemotingCommand sendResult = sendFinalMessage(msgInner):调用存储模块存储至 Broker

      this.brokerController...deletePrepareMessage(result.getPrepareMessage())向删除(OP)队列添加消息,消息体的数据是 halfMsg 的 queueOffset,表示半消息队列指定的 offset 的消息已被删除

      • if (this...putOpMessage(msgExt, TransactionalMessageUtil.REMOVETAG)):添加一条 OP 数据
        • MessageQueue messageQueue:新建一个消息队列,OP 队列
        • return addRemoveTagInTransactionOp(messageExt, messageQueue):添加数据
          • Message message:创建 OP 消息
          • writeOp(message, messageQueue):写入 OP 消息
    • else if (MessageSysFlag.TRANSACTION_ROLLBACK_TYPE)事务回滚

      this.brokerController...deletePrepareMessage(result.getPrepareMessage())也需要向 OP 队列添加消息


Consumer

消费者类

默认消费

DefaultMQPushConsumer 类是默认的消费者类

成员变量:

  • 消费者实现类:

    1
    protected final transient DefaultMQPushConsumerImpl defaultMQPushConsumerImpl;
  • 消费属性:

    1
    2
    private String consumerGroup;									// 消费者组
    private MessageModel messageModel = MessageModel.CLUSTERING; // 消费模式,默认集群模式
  • 订阅信息:key 是主题,value 是过滤表达式,一般是 tag

    1
    private Map<String, String > subscription = new HashMap<String, String>()
  • 消息监听器:消息处理逻辑,并发消费 MessageListenerConcurrently,顺序(分区)消费 MessageListenerOrderly

    1
    private MessageListener messageListener;
  • 消费位点:当从 Broker 获取当前组内该 queue 的 offset 不存在时,consumeFromWhere 才有效,默认值代表从队列的最后 offset 开始消费,当队列内再有一条新的 msg 加入时,消费者才会去消费

    1
    private ConsumeFromWhere consumeFromWhere = ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET;
  • 消费时间戳:当消费位点配置的是 CONSUME_FROM_TIMESTAMP 时,并且服务器 Group 内不存在该 queue 的 offset 时,会使用该时间戳进行消费

    1
    private String consumeTimestamp = UtilAll.timeMillisToHumanString3(System.currentTimeMillis() - (1000 * 60 * 30));// 消费者创建时间 - 30秒,转换成 格式: 年月日小时分钟秒,比如 20220203171201
  • 队列分配策略:主题下的队列分配策略,RebalanceImpl 对象依赖该算法

    1
    private AllocateMessageQueueStrategy allocateMessageQueueStrategy;
  • 消费进度存储器:

    1
    private OffsetStore offsetStore;

核心方法:

  • start():启动消费者

    1
    public void start()
  • shutdown():关闭消费者

    1
    public void shutdown()
  • registerMessageListener():注册消息监听器

    1
    public void registerMessageListener(MessageListener messageListener) 
  • subscribe():添加订阅信息,将订阅信息放入负载均衡对象的 subscriptionInner 中

    1
    public void subscribe(String topic, String subExpression)
  • unsubscribe():删除订阅指定主题的信息

    1
    public void unsubscribe(String topic)
  • suspend():停止消费

    1
    public void suspend()
  • resume():恢复消费

    1
    public void resume()

默认实现

DefaultMQPushConsumerImpl 是默认消费者的实现类

成员变量:

  • 客户端实例:整个进程内只有一个客户端实例对象

    1
    private MQClientInstance mQClientFactory;
  • 消费者实例:门面对象

    1
    private final DefaultMQPushConsumer defaultMQPushConsumer;
  • 负载均衡:分配订阅主题的队列给当前消费者,20 秒钟一个周期执行 Rebalance 算法(客户端实例触发)

    1
    private final RebalanceImpl rebalanceImpl = new RebalancePushImpl(this);
  • 消费者信息:

    1
    2
    3
    4
    private final long consumerStartTimestamp;	// 消费者启动时间
    private volatile ServiceState serviceState; // 消费者状态
    private volatile boolean pause = false; // 是否暂停
    private boolean consumeOrderly = false; // 是否顺序消费
  • 拉取消息:封装拉消息的 API,服务器 Broker 返回结果中包含下次 Pull 时推荐的 BrokerId,根据本次请求数据的冷热程度进行推荐

    1
    private PullAPIWrapper pullAPIWrapper;
  • 消息消费服务:并发消费和顺序消费

    1
    private ConsumeMessageService consumeMessageService;
  • 流控:

    1
    2
    private long queueFlowControlTimes = 0;			// 队列流控次数,默认每1000次流控,进行一次日志打印
    private long queueMaxSpanFlowControlTimes = 0; // 流控使用,控制打印日志
  • HOOK:钩子方法

    1
    2
    3
    4
    // 过滤消息 hook
    private final ArrayList<FilterMessageHook> filterMessageHookList;
    // 消息执行hook,在消息处理前和处理后分别执行 hook.before hook.after 系列方法
    private final ArrayList<ConsumeMessageHook> consumeMessageHookList;

核心方法:

  • start():加锁保证线程安全

    1
    public synchronized void start() 
    • this.checkConfig():检查配置,包括组名、消费模式、订阅信息、消息监听器等
    • this.copySubscription():拷贝订阅信息到 RebalanceImpl 对象
      • this.rebalanceImpl.getSubscriptionInner().put(topic, subscriptionData):将订阅信息加入 rbl 的 map 中
      • this.messageListenerInner = ...getMessageListener():将消息监听器保存到实例对象
      • switch (this.defaultMQPushConsumer.getMessageModel()):判断消费模式,广播模式下直接返回
      • final String retryTopic:创建当前消费者组重试的主题名,规则 %RETRY%ConsumerGroup
      • SubscriptionData subscriptionData = FilterAPI.buildSubscriptionData():创建重试主题的订阅数据对象
      • this.rebalanceImpl.getSubscriptionInner().put(retryTopic, subscriptionData):将创建的重试主题加入到 rbl 对象的 map 中,消息重试时会加入到该主题,消费者订阅这个主题之后,就有机会再次拿到该消息进行消费处理
    • this.mQClientFactory = ...getOrCreateMQClientInstance():获取客户端实例对象
    • this.rebalanceImpl.:初始化负载均衡对象,设置队列分配策略对象到属性中
    • this.pullAPIWrapper = new PullAPIWrapper():创建拉消息 API 对象,内部封装了查询推荐主机算法
    • this.pullAPIWrapper.registerFilterMessageHook(filterMessageHookList):将过滤 Hook 列表注册到该对象内,消息拉取下来之后会执行该 Hook,再进行一次自定义的消息过滤
    • this.offsetStore = new RemoteBrokerOffsetStore():默认集群模式下创建消息进度存储器
    • this.consumeMessageService = ...:根据消息监听器的类型创建消费服务
    • this.consumeMessageService.start():启动消费服务
    • boolean registerOK = mQClientFactory.registerConsumer()将消费者注册到客户端实例中,客户端提供的服务:
      • 心跳服务:把订阅数据同步到订阅主题的 Broker
      • 拉消息服务:内部 PullMessageService 启动线程,基于 PullRequestQueue 工作,消费者负载均衡分配到队列后会向该队列提交 PullRequest
      • 队列负载服务:每 20 秒调用一次 consumer.doRebalance() 接口
      • 消息进度持久化
      • 动态调整消费者、消费服务线程池
    • mQClientFactory.start():启动客户端实例
    • this.updateTopic:从 nameserver 获取主题路由数据,生成主题集合放入 rbl 对象的 table
    • this.mQClientFactory.checkClientInBroker():检查服务器是否支持消息过滤模式,一般使用 tag 过滤,服务器默认支持
    • this.mQClientFactory.sendHeartbeatToAllBrokerWithLock():向所有已知的 Broker 节点,发送心跳数据
    • this.mQClientFactory.rebalanceImmediately():唤醒 rbl 线程,触发负载均衡执行

负载均衡

实现方式

MQClientInstance#start 中会启动负载均衡服务 RebalanceService:

1
2
3
4
5
6
7
8
9
public void run() {
// 检查停止标记
while (!this.isStopped()) {
// 休眠 20 秒,防止其他线程饥饿,所以【每 20 秒负载均衡一次】
this.waitForRunning(waitInterval);
// 调用客户端实例的负载均衡方法,底层【会遍历所有消费者,调用消费者的负载均衡】
this.mqClientFactory.doRebalance();
}
}

RebalanceImpl 类成员变量:

  • 分配给当前消费者的处理队列:处理消息队列集合,ProcessQueue 是 MQ 队列在消费者端的快照

    1
    protected final ConcurrentMap<MessageQueue, ProcessQueue> processQueueTable;
  • 消费者订阅主题的队列信息:

    1
    protected final ConcurrentMap<String/* topic */, Set<MessageQueue>> topicSubscribeInfoTable;
  • 订阅数据:

    1
    protected final ConcurrentMap<String/* topic */, SubscriptionData> subscriptionInner;
  • 队列分配策略:

    1
    protected AllocateMessageQueueStrategy allocateMessageQueueStrategy;

成员方法:

  • doRebalance():负载均衡方法,以每个消费者实例为粒度进行负载均衡

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public void doRebalance(final boolean isOrder) {
    // 获取当前消费者的订阅数据
    Map<String, SubscriptionData> subTable = this.getSubscriptionInner();
    if (subTable != null) {
    // 遍历所有的订阅主题
    for (final Entry<String, SubscriptionData> entry : subTable.entrySet()) {
    // 获取订阅的主题
    final String topic = entry.getKey();
    // 按照主题进行负载均衡
    this.rebalanceByTopic(topic, isOrder);
    }
    }
    // 将分配到当前消费者的队列进行过滤,不属于当前消费者订阅主题的直接移除
    this.truncateMessageQueueNotMyTopic();
    }

    集群模式下:

    • Set<MessageQueue> mqSet = this.topicSubscribeInfoTable.get(topic):订阅的主题下的全部队列信息

    • cidAll = this...findConsumerIdList(topic, consumerGroup):从服务器获取消费者组下的全部消费者 ID

    • Collections.sort(mqAll):主题 MQ 队列和消费者 ID 都进行排序,保证每个消费者的视图一致性

    • allocateResult = strategy.allocate()调用队列分配策略,给当前消费者进行分配 MessageQueue(下一节)

    • boolean changed = this.updateProcessQueueTableInRebalance(...)更新队列处理集合,mqSet 是 rbl 算法分配到当前消费者的 MQ 集合

      • while (it.hasNext()):遍历当前消费者的所有处理队列

      • if (mq.getTopic().equals(topic)):该 MQ 是 本次 rbl 分配算法计算的主题

      • if (!mqSet.contains(mq)):该 MQ 经过 rbl 计算之后,被分配到其它 Consumer 节点

        pq.setDropped(true):将删除状态设置为 true

        if (this.removeUnnecessaryMessageQueue(mq, pq)):删除不需要的 MQ 队列

        • this...getOffsetStore().persist(mq):在 MQ 归属的 Broker 节点持久化消费进度

        • this...getOffsetStore().removeOffset(mq):删除该 MQ 在本地的消费进度

        • if (this.defaultMQPushConsumerImpl.isConsumeOrderly() &&):是否是顺序消费和集群模式

          if (pq.getLockConsume().tryLock(1000, ..)): 获取锁成功,说明顺序消费任务已经停止消费工作

          return this.unlockDelay(mq, pq)释放锁 Broker 端的队列锁,向服务器发起 oneway 的解锁请求

          • if (pq.hasTempMessage()):队列中有消息,延迟 20 秒释放队列分布式锁,确保全局范围内只有一个消费任务 运行中
          • else:当前消费者本地该消费任务已经退出,直接释放锁

          else:顺序消费任务正在消费一批消息,不可打断,增加尝试获取锁的次数

        it.remove():从 processQueueTable 移除该 MQ

      • else if (pq.isPullExpired()):说明当前 MQ 还是被当前 Consumer 消费,此时判断一下是否超过 2 分钟未到服务器 拉消息,如果条件成立进行上述相同的逻辑

      • for (MessageQueue mq : mqSet):开始处理当前主题新分配到当前节点的队列

        if (isOrder && !this.lock(mq))顺序消息为了保证有序性,需要获取队列锁

        ProcessQueue pq = new ProcessQueue():为每个新分配的消息队列创建快照队列

        long nextOffset = this.computePullFromWhere(mq)从服务端获取新分配的 MQ 的消费进度

        ProcessQueue pre = this.processQueueTable.putIfAbsent(mq, pq):保存到处理队列集合

        PullRequest pullRequest = new PullRequest()创建拉取请求对象

      • this.dispatchPullRequest(pullRequestList):放入 PullMessageService 的本地阻塞队列内,用于拉取消息工作

  • lockAll():续约锁,对消费者的所有队列进行续约

    1
    public void lockAll()
    • HashMap<String, Set<MessageQueue>> brokerMqs:将分配给当前消费者的全部 MQ 按照 BrokerName 分组

    • while (it.hasNext()):遍历所有的分组

    • final Set<MessageQueue> mqs:获取该 Broker 上分配给当前消费者的 queue 集合

    • FindBrokerResult findBrokerResult:查询 Broker 主节点信息

    • LockBatchRequestBody requestBody:创建请求对象,填充属性

    • Set<MessageQueue> lockOKMQSet以组为单位向 Broker 发起批量续约锁的同步请求,返回成功的队列集合

    • for (MessageQueue mq : lockOKMQSet):遍历续约锁成功的 MQ

      processQueue.setLocked(true)分布式锁状态设置为 true,表示允许顺序消费

      processQueue.setLastLockTimestamp(System.currentTimeMillis()):设置上次获取锁的时间为当前时间

    • for (MessageQueue mq : mqs):遍历当前 Broker 上的所有队列集合

      if (!lockOKMQSet.contains(mq)):条件成立说明续约锁失败

      processQueue.setLocked(false)分布式锁状态设置为 false,表示不允许顺序消费


队列分配

AllocateMessageQueueStrategy 类是队列的分配策略

  • 平均分配:AllocateMessageQueueAveragely 类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // 参数一:消费者组       								参数二:当前消费者id   
    // 参数三:主题的全部队列,包括所有 broker 上该主题的 mq 参数四:全部消费者id集合
    public List<MessageQueue> allocate(String consumerGroup, String currentCID, List<MessageQueue> mqAll, List<String> cidAll) {
    // 获取当前消费者在全部消费者中的位置,【全部消费者是已经排序好的,排在前面的优先分配更多的队列】
    int index = cidAll.indexOf(currentCID);
    // 平均分配完以后,还剩余的待分配的 mq 的数量
    int mod = mqAll.size() % cidAll.size();
    // 首先判断整体的 mq 的数量是否小于消费者的数量,小于消费者的数量就说明不够分的,先分一个
    int averageSize = mqAll.size() <= cidAll.size() ? 1 :
    // 成立需要多分配一个队列,因为更靠前
    (mod > 0 && index < mod ? mqAll.size() / cidAll.size() + 1 : mqAll.size() / cidAll.size());
    // 获取起始的分配位置
    int startIndex = (mod > 0 && index < mod) ? index * averageSize : index * averageSize + mod;
    // 防止索引越界
    int range = Math.min(averageSize, mqAll.size() - startIndex);
    // 开始分配,【挨着分配,是直接就把当前的 消费者分配完成】
    for (int i = 0; i < range; i++) {
    result.add(mqAll.get((startIndex + i) % mqAll.size()));
    }
    return result;
    }

    队列排序后:Q1 → Q2 → Q3,消费者排序后 C1 → C2 → C3

  • 轮流分配:AllocateMessageQueueAveragelyByCircle

  • 指定机房平均分配:AllocateMessageQueueByMachineRoom,前提是 Broker 的命名规则为 机房名@BrokerName


拉取服务

实现方式

MQClientInstance#start 中会启动消息拉取服务:PullMessageService

1
2
3
4
5
6
7
8
9
10
11
12
13
public void run() {
// 检查停止标记,【循环拉取】
while (!this.isStopped()) {
try {
// 从阻塞队列中获取拉消息请求
PullRequest pullRequest = this.pullRequestQueue.take();
// 拉取消息,获取请求对应的使用当前消费者组中的哪个消费者,调用消费者的 pullMessage 方法
this.pullMessage(pullRequest);
} catch (Exception e) {
log.error("Pull Message Service Run Method exception", e);
}
}
}

DefaultMQPushConsumerImpl#pullMessage:

  • ProcessQueue processQueue = pullRequest.getProcessQueue():获取请求对应的快照队列,并判断是否是删除状态

  • this.executePullRequestLater():如果当前消费者不是运行状态,则拉消息任务延迟 3 秒后执行,如果是暂停状态延迟 1 秒

  • 流控的逻辑

    long cachedMessageCount = processQueue.getMsgCount().get():获取消费者本地该 queue 快照内缓存的消息数量,如果大于 1000 条,进行流控,延迟 50 毫秒

    long cachedMessageSizeInMiB: 消费者本地该 queue 快照内缓存的消息容量 size,超过 100m 消息未被消费进行流控

    if(processQueue.getMaxSpan() > 2000):消费者本地缓存消息第一条消息最后一条消息跨度超过 2000 进行流控

  • SubscriptionData subscriptionData:本次拉消息请求订阅的主题数据,如果调用了 unsubscribe(主题) 将会获取为 null

  • PullCallback pullCallback = new PullCallback()拉消息处理回调对象

    • pullResult = ...processPullResult():预处理 PullResult 结果,将服务器端指定 MQ 的拉消息下一次的推荐节点保存到 pullFromWhichNodeTable 中,并进行消息过滤

    • case FOUND:正常拉取到消息

      pullRequest.setNextOffset(pullResult.getNextBeginOffset()):更新 pullRequest 对象下一次拉取消息的位点

      if (pullResult.getMsgFoundList() == null...):消息过滤导致消息全部被过滤掉,需要立马发起下一次拉消息

      boolean .. = processQueue.putMessage():将服务器拉取的消息集合加入到消费者本地的 processQueue 内

      DefaultMQPushConsumerImpl...submitConsumeRequest()提交消费任务,分为顺序消费和并发消费

      Defaul..executePullRequestImmediately(pullRequest):将更新过 nextOffset 字段的 PullRequest 对象,再次放到 pullMessageService 的阻塞队列中,形成闭环

    • case NO_NEW_MSG ||NO_MATCHED_MSG表示本次 pull 没有新的可消费的信息

      pullRequest.setNextOffset():更新更新 pullRequest 对象下一次拉取消息的位点

      Defaul..executePullRequestImmediately(pullRequest):再次拉取请求

    • case OFFSET_ILLEGAL本次 pull 时使用的 offset 是无效的,即 offset > maxOffset || offset < minOffset

      pullRequest.setNextOffset():调整 pullRequest.nextOffset 为正确的 offset

      pullRequest.getProcessQueue().setDropped(true):设置该 processQueue 为删除状态,如果有该 queue 的消费任务,消费任务会马上停止

      DefaultMQPushConsumerImpl.this.executeTaskLater():提交异步任务,10 秒后去执行

      • DefaultMQPushConsumerImpl...updateOffset():更新 offsetStore 该 MQ 的 offset 为正确值,内部直接替换

      • DefaultMQPushConsumerImpl...persist():持久化该 messageQueue 的 offset 到 Broker 端

      • DefaultMQPushConsumerImpl...removeProcessQueue(): 删除该消费者该 messageQueue 对应的 processQueue

      • 这里没有再次提交 pullRequest 到 pullMessageService 的队列,那该队列不再拉消息了吗?

        负载均衡 rbl 程序会重建该队列的 processQueue,重建完之后会为该队列创建新的 PullRequest 对象

  • int sysFlag = PullSysFlag.buildSysFlag()构建标志对象,sysFlag 高 4 位未使用,低 4 位使用,从左到右 0000 0011

    • 第一位:表示是否提交消费者本地该队列的 offset,一般是 1
    • 第二位:表示是否允许服务器端进行长轮询,一般是 1
    • 第三位:表示是否提交消费者本地该主题的订阅数据,一般是 0
    • 第四位:表示是否为类过滤,一般是 0
  • this.pullAPIWrapper.pullKernelImpl():拉取消息的核心方法


封装对象

PullAPIWrapper 类封装了拉取消息的 API

成员变量:

  • 推荐拉消息使用的主机 ID:

    1
    private ConcurrentMap<MessageQueue, AtomicLong/* brokerId */> pullFromWhichNodeTable

成员方法:

  • pullKernelImpl():拉消息

    • FindBrokerResult findBrokerResult本地查询指定 BrokerName 的地址信息,推荐节点或者主节点

    • if (null == findBrokerResult):查询不到,就到 Namesrv 获取指定 topic 的路由数据

    • if (findBrokerResult.isSlave()):成立说明 findBrokerResult 表示的主机为 slave 节点,slave 不存储 offset 信息

      sysFlagInner = PullSysFlag.clearCommitOffsetFlag(sysFlagInner):将 sysFlag 标记位中 CommitOffset 的位置为 0

    • PullMessageRequestHeader requestHeader:创建请求头对象,封装所有的参数

    • PullResult pullResult = this.mQClientFactory.getMQClientAPIImpl().pullMessage():调用客户端实例的方法,核心逻辑就是将业务数据转化为 RemotingCommand 通过 NettyRemotingClient 的 IO 进行通信

      • RemotingCommand request:创建网络层传输对象 RemotingCommand 对象,请求 ID 为 PULL_MESSAGE = 11

      • return this.pullMessageSync(...):此处是异步调用,处理结果放入 ResponseFuture 中,参考服务端小节的处理器类 NettyServerHandler#processMessageReceived 方法

  • RemotingCommand response = responseFuture.getResponseCommand():获取服务器端响应数据 response

    • PullResult pullResult:从 response 内提取出来拉消息结果对象,将响应头 PullMessageResponseHeader 对象中信息填充到 PullResult 中,列出两个重要的字段:
    • private Long suggestWhichBrokerId:服务端建议客户端下次 Pull 时选择的 BrokerID
    • private Long nextBeginOffset:客户端下次 Pull 时使用的 offset 信息
  • pullCallback.onSuccess(pullResult):将 PullResult 交给拉消息结果处理回调对象,调用 onSuccess 方法


拉取处理

处理器

BrokerStartup#createBrokerController 方法中创建了 BrokerController 并进行初始化,调用 registerProcessor() 方法将处理器 PullMessageProcessor 注册到 NettyRemotingServer 中,对应的请求 ID 为 PULL_MESSAGE = 11,NettyServerHandler 在处理请求时通过请求 ID 会获取处理器执行 processRequest 方法

1
2
// 参数一:服务器与客户端 netty 通道; 参数二:客户端请求; 参数三:是否允许服务器端长轮询,默认 true
private RemotingCommand processRequest(final Channel channel, RemotingCommand request, boolean brokerAllowSuspend)
  • RemotingCommand response:创建响应对象,设置为响应类型的请求,响应头是 PullMessageResponseHeader

  • final PullMessageResponseHeader responseHeader:获取响应对象的 header

  • final PullMessageRequestHeader requestHeader:解析出请求头 PullMessageRequestHeader

  • response.setOpaque(request.getOpaque()):设置 opaque 属性,客户端根据该字段获取 ResponseFuture 进行处理

  • 进行一些鉴权的逻辑:是否允许长轮询、提交 offset、topicConfig 是否是空、队列 ID 是否合理

  • ConsumerGroupInfo consumerGroupInfo:获取消费者组信息,包含全部的消费者和订阅数据

  • subscriptionData = consumerGroupInfo.findSubscriptionData()获取指定主题的订阅数据

  • if (!ExpressionType.isTagType():表达式匹配

  • MessageFilter messageFilter:创建消息过滤器,一般是通过 tagCode 进行过滤

  • DefaultMessageStore.getMessage()查询消息的核心逻辑,在 Broker 端查询消息(存储端笔记详解了该源码)

  • response.setRemark():设置此次响应的状态

  • responseHeader.set..:设置响应头对象的一些字段

  • switch (this.brokerController.getMessageStoreConfig().getBrokerRole()):如果当前主机节点角色为 slave 并且从节点读并未开启的话,直接给客户端 一个状态 PULL_RETRY_IMMEDIATELY,并设置为下次从主节点读

  • if (this.brokerController.getBrokerConfig().isSlaveReadEnable()):消费太慢,下次从另一台机器拉取

  • switch (getMessageResult.getStatus()):根据 getMessageResult 的状态设置 response 的 code

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public enum GetMessageStatus {
    FOUND, // 查询成功
    NO_MATCHED_MESSAGE, // 未查询到到消息,服务端过滤 tagCode
    MESSAGE_WAS_REMOVING, // 查询时赶上 CommitLog 清理过期文件,导致查询失败,立刻尝试
    OFFSET_FOUND_NULL, // 查询时赶上 ConsumerQueue 清理过期文件,导致查询失败,【进行长轮询】
    OFFSET_OVERFLOW_BADLY, // pullRequest.offset 越界 maxOffset
    OFFSET_OVERFLOW_ONE, // pullRequest.offset == CQ.maxOffset,【进行长轮询】
    OFFSET_TOO_SMALL, // pullRequest.offset 越界 minOffset
    NO_MATCHED_LOGIC_QUEUE, // 没有匹配到逻辑队列
    NO_MESSAGE_IN_QUEUE, // 空队列,创建队列也是因为查询导致,【进行长轮询】
    }
  • switch (response.getCode()):根据 response 状态做对应的业务处理

    case ResponseCode.SUCCESS:查询成功

    • final byte[] r = this.readGetMessageResult():本次 pull 出来的全部消息导入 byte 数组
    • response.setBody(r):将消息的 byte 数组保存到 response body 字段

    case ResponseCode.PULL_NOT_FOUND:产生这种情况大部分原因是 pullRequest.offset == queue.maxOffset,说明已经没有需要获取的消息,此时如果直接返回给客户端,客户端会立刻重新请求,还是继续返回该状态,频繁拉取服务器导致服务器压力大,所以此处需要长轮询

    • if (brokerAllowSuspend && hasSuspendFlag):brokerAllowSuspend = true,当长轮询结束再次执行 processRequest 时该参数为 false,所以每次 Pull 请求至多在服务器端长轮询控制一次
    • PullRequest pullRequest = new PullRequest():创建长轮询 PullRequest 对象
    • this.brokerController...suspendPullRequest(topic, queueId, pullRequest):将长轮询请求对象交给长轮询服务
      • String key = this.buildKey(topic, queueId):构建一个 topic@queueId 的 key
      • ManyPullRequest mpr = this.pullRequestTable.get(key):从拉请求表中获取对象
      • mpr.addPullRequest(pullRequest)将 PullRequest 对象放入到长轮询的请求集合中
    • response = null:响应设置为 null 内部的 callBack 就不会给客户端发送任何数据,不进行通信,否则就又开始重新请求
  • boolean storeOffsetEnable:允许长轮询、sysFlag 表示提交消费者本地该队列的offset、当前 broker 节点角色为 master 节点三个条件成立,才在 Broker 端存储消费者组内该主题的指定 queue 的消费进度

  • return response:返回 response,不为 null 时外层 processRequestCommand 的 callback 会将数据写给客户端


长轮询

PullRequestHoldService 类负责长轮询,BrokerController#start 方法中调用了 this.pullRequestHoldService.start() 启动该服务

核心方法:

  • run():核心运行方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public void run() {
    // 循环运行
    while (!this.isStopped()) {
    if (this.brokerController.getBrokerConfig().isLongPollingEnable()) {
    // 服务器开启长轮询开关:每次循环休眠5秒
    this.waitForRunning(5 * 1000);
    } else {
    // 服务器关闭长轮询开关:每次循环休眠1秒
    this.waitForRunning(...);
    }
    // 检查持有的请求
    this.checkHoldRequest();
    // .....
    }
    }
  • checkHoldRequest():检查所有的请求

    • for (String key : this.pullRequestTable.keySet())处理所有的 topic@queueId 的逻辑
    • String[] kArray = key.split(TOPIC_QUEUEID_SEPARATOR):key 按照 @ 拆分,得到 topic 和 queueId
    • long offset = this...getMaxOffsetInQueue(topic, queueId): 到存储模块查询该 ConsumeQueue 的最大 offset
    • this.notifyMessageArriving(topic, queueId, offset):通知消息到达
  • notifyMessageArriving():通知消息到达的逻辑,ReputMessageService 消息分发服务也会调用该方法

    • ManyPullRequest mpr = this.pullRequestTable.get(key):获取对应的的 manyPullRequest 对象
    • List<PullRequest> requestList:获取该队列下的所有 PullRequest,并进行遍历
    • List<PullRequest> replayList:当某个 pullRequest 不超时,并且对应的 CQ.maxOffset <= pullRequest.offset,就将该 PullRequest 再放入该列表
    • long newestOffset:该值为 CQ 的 maxOffset
    • if (newestOffset > request.getPullFromThisOffset())请求对应的队列内可以 pull 消息了,结束长轮询
    • boolean match:进行过滤匹配
    • this.brokerController...executeRequestWhenWakeup():将满足条件的 pullRequest 再次提交到线程池内执行
      • final RemotingCommand response:执行 processRequest 方法,并且不会触发长轮询
      • channel.writeAndFlush(response).addListene()将结果数据发送给客户端
    • if (System.currentTimeMillis() >= ...):判断该 pullRequest 是否超时,超时后的也是重新提交到线程池,并且不进行长轮询
    • mpr.addPullRequest(replayList):将未满足条件的 PullRequest 对象再次添加到 ManyPullRequest 属性中

结果类

GetMessageResult 类成员信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class GetMessageResult {
// 查询消息时,最底层都是 mappedFile 支持的查询,查询时返回给外层一个 SelectMappedBufferResult,
// mappedFile 每查询一次都会 refCount++ ,通过SelectMappedBufferResult持有mappedFile,完成资源释放的句柄
private final List<SelectMappedBufferResult> messageMapedList =
new ArrayList<SelectMappedBufferResult>(100);

// 该List内存储消息,每一条消息都被转成 ByteBuffer 表示了
private final List<ByteBuffer> messageBufferList = new ArrayList<ByteBuffer>(100);
// 查询结果状态
private GetMessageStatus status;
// 客户端下次再向当前Queue拉消息时,使用的 offset
private long nextBeginOffset;
// 当前queue最小offset
private long minOffset;
// 当前queue最大offset
private long maxOffset;
// 消息总byte大小
private int bufferTotalSize = 0;
// 服务器建议客户端下次到该 queue 拉消息时是否使用 【从节点】
private boolean suggestPullingFromSlave = false;
}

队列快照

成员属性

ProcessQueue 类是消费队列的快照

成员变量:

  • 属性字段:

    1
    2
    3
    4
    5
    6
    7
    private final AtomicLong msgCount = new AtomicLong();	// 队列中消息数量
    private final AtomicLong msgSize = new AtomicLong(); // 消息总大小
    private volatile long queueOffsetMax = 0L; // 快照中最大 offset
    private volatile boolean dropped = false; // 快照是否移除
    private volatile long lastPullTimestamp = current; // 上一次拉消息的时间
    private volatile long lastConsumeTimestamp = current; // 上一次消费消息的时间
    private volatile long lastLockTimestamp = current; // 上一次获取锁的时间
  • 消息容器:key 是消息偏移量,val 是消息

    1
    private final TreeMap<Long, MessageExt> msgTreeMap = new TreeMap<Long, MessageExt>();
  • 顺序消费临时容器

    1
    private final TreeMap<Long, MessageExt> consumingMsgOrderlyTreeMap = new TreeMap<Long, MessageExt>();
  • 锁:

    1
    2
    private final ReadWriteLock lockTreeMap;		// 读写锁
    private final Lock lockConsume; // 重入锁,【顺序消费使用】
  • 顺序消费状态:

    1
    2
    private volatile boolean locked = false;		// 是否是锁定状态
    private volatile boolean consuming = false; // 是否是消费中

成员方法

核心成员方法

  • putMessage():将 Broker 拉取下来的 msgs 存储到快照队列内,返回为 true 表示提交顺序消费任务,false 表示不提交

    1
    public boolean putMessage(final List<MessageExt> msgs)
    • this.lockTreeMap.writeLock().lockInterruptibly():获取写锁

    • for (MessageExt msg : msgs):遍历 msgs 全部加入 msgTreeMap,key 是消息的 queueOffset

    • if (!msgTreeMap.isEmpty() && !this.consuming)消息容器中存在未处理的消息,并且不是消费中的状态

      dispatchToConsume = true:代表需要提交顺序消费任务

      this.consuming = true:设置为顺序消费执行中的状态

    • this.lockTreeMap.writeLock().unlock():释放写锁

  • removeMessage():移除已经消费的消息,参数是已经消费的消息集合,并发消费使用

    1
    public long removeMessage(final List<MessageExt> msgs)
    • long result = -1:结果初始化为 -1
    • this.lockTreeMap.writeLock().lockInterruptibly():获取写锁
    • this.lastConsumeTimestamp = now:更新上一次消费消息的时间为现在
    • if (!msgTreeMap.isEmpty()):判断消息容器是否是空,是空直接返回 -1
    • result = this.queueOffsetMax + 1:设置结果,删除完后消息容器为空时返回
    • for (MessageExt msg : msgs):将已经消费的消息全部从 msgTreeMap 移除
    • if (!msgTreeMap.isEmpty()):移除后容器内还有待消费的消息,获取第一条消息 offset 返回
    • this.lockTreeMap.writeLock().unlock():释放写锁
  • takeMessages():获取一批消息,顺序消费使用

    1
    public List<MessageExt> takeMessages(final int batchSize)
    • this.lockTreeMap.writeLock().lockInterruptibly():获取写锁
    • this.lastConsumeTimestamp = now:更新上一次消费消息的时间为现在
    • for (int i = 0; i < batchSize; i++):从头节点开始获取消息
    • result.add(entry.getValue()):将消息放入结果集合
    • consumingMsgOrderlyTreeMap.put():将消息加入顺序消费容器中
    • if (result.isEmpty()):条件成立说明顺序消费容器本地快照内的消息全部处理完了,当前顺序消费任务需要停止
    • consuming = false:消费状态置为 false
    • this.lockTreeMap.writeLock().unlock():释放写锁
  • commit():处理完一批消息后调用,顺序消费使用

    1
    public long commit()
    • this.lockTreeMap.writeLock().lockInterruptibly():获取写锁
    • Long offset = this.consumingMsgOrderlyTreeMap.lastKey():获取顺序消费临时容器最后一条数据的 key
    • msgCount, msgSize:更新顺序消费相关的字段
    • this.consumingMsgOrderlyTreeMap.clear():清空顺序消费容器的数据
    • return offset + 1消费者下一条消费的位点
    • this.lockTreeMap.writeLock().unlock():释放写锁
  • cleanExpiredMsg():清除过期消息

    1
    public void cleanExpiredMsg(DefaultMQPushConsumer pushConsumer)
    • if (pushConsumer.getDefaultMQPushConsumerImpl().isConsumeOrderly()) :顺序消费不执行过期清理逻辑
    • int loop = msgTreeMap.size() < 16 ? msgTreeMap.size() : 16:最多循环 16 次
    • if (!msgTreeMap.isEmpty() &&):如果容器中第一条消息的消费开始时间与当前系统时间差值 > 15min,则取出该消息
    • else:直接跳出循环,因为快照队列内的消息是有顺序的,第一条消息不过期,其他消息都不过期
    • pushConsumer.sendMessageBack(msg, 3)消息回退到服务器,设置该消息的延迟级别为 3
    • if (!msgTreeMap.isEmpty() && msg.getQueueOffset() == msgTreeMap.firstKey()):条件成立说明消息回退期间,该目标消息并没有被消费任务成功消费
    • removeMessage(Collections.singletonList(msg)):从 treeMap 将该回退成功的 msg 删除

并发消费

成员属性

ConsumeMessageConcurrentlyService 负责并发消费服务

成员变量:

  • 消息监听器:封装处理消息的逻辑,该监听器由开发者实现,并注册到 defaultMQPushConsumer

    1
    private final MessageListenerConcurrently messageListener;
  • 消费属性:

    1
    2
    private final BlockingQueue<Runnable> consumeRequestQueue;	// 消费任务队列
    private final String consumerGroup; // 消费者组
  • 线程池:

    1
    2
    3
    private final ThreadPoolExecutor consumeExecutor;				// 消费任务线程池,默认 20
    private final ScheduledExecutorService scheduledExecutorService;// 调度线程池,延迟提交消费任务
    private final ScheduledExecutorService cleanExpireMsgExecutors; // 清理过期消息任务线程池,15min 一次

成员方法

ConsumeMessageConcurrentlyService 并发消费核心方法

  • start():启动消费服务,DefaultMQPushConsumerImpl 启动时会调用该方法

    1
    2
    3
    4
    5
    public void start() {
    // 提交“清理过期消息任务”任务,延迟15min之后执行,之后每15min执行一次
    this.cleanExpireMsgExecutors.scheduleAtFixedRate(() -> cleanExpireMsg()},
    15, 15, TimeUnit.MINUTES);
    }
  • cleanExpireMsg():清理过期消息任务

    1
    private void cleanExpireMsg()
    • Iterator<Map.Entry<MessageQueue, ProcessQueue>> it :获取分配给当前消费者的队列
    • while (it.hasNext()):遍历所有的队列
    • pq.cleanExpiredMsg(this.defaultMQPushConsumer):调用队列快照 ProcessQueue 清理过期消息的方法
  • submitConsumeRequest():提交消费请求

    1
    2
    3
    4
    5
    // 参数一:从服务器 pull 下来的这批消息
    // 参数二:消息归属 mq 在消费者端的 processQueue,提交消费任务之前,msgs已经加入到该pq内了
    // 参数三:消息归属队列
    // 参数四:并发消息此参数无效
    public void submitConsumeRequest(List<MessageExt> msgs, ProcessQueue processQueue, MessageQueue messageQueue, boolean dispatchToConsume)
    • final int consumeBatchSize一个消费任务可消费的消息数量,默认为 1

    • if (msgs.size() <= consumeBatchSize):判断一个消费任务是否可以提交

      ConsumeRequest consumeRequest:封装为消费请求

      this.consumeExecutor.submit(consumeRequest):提交消费任务,异步执行消息的处理

    • else:说明消息较多,需要多个消费任务

      for (int total = 0; total < msgs.size(); ):将消息拆分成多个消费任务

  • processConsumeResult():处理消费结果

    1
    2
    // 参数一:消费结果状态;  参数二:消费上下文;  参数三:当前消费任务
    public void processConsumeResult(status, context, consumeRequest)
    • switch (status):根据消费结果状态进行处理

    • case CONSUME_SUCCESS:消费成功

      if (ackIndex >= consumeRequest.getMsgs().size()):消费成功的话,ackIndex 设置成 消费消息数 - 1 的值,比如有 5 条消息,这里就设置为 4

      ok, failed:ok 设置为消息数量,failed 设置为 0

    • case RECONSUME_LATER:消费失败

      ackIndex = -1:设置为 -1

    • switch (this.defaultMQPushConsumer.getMessageModel()):判断消费模式,默认是集群模式

    • for (int i = ackIndex + 1; i < msgs.size(); i++):当消费失败时 ackIndex 为 -1,i 的起始值为 0,该消费任务内的全部消息都会尝试回退给服务器

    • MessageExt msg:提取一条消息

    • boolean result = this.sendMessageBack(msg, context)发送消息回退,同步发送

    • if (!result):回退失败的消息,将消息的重试属性加 1,并加入到回退失败的集合

    • if (!msgBackFailed.isEmpty()):回退失败集合不为空

    consumeRequest.getMsgs().removeAll(msgBackFailed):将回退失败的消息从当前消费任务的 msgs 集合内移除

    this.submitConsumeRequestLater()回退失败的消息会再次提交消费任务,延迟 5 秒钟后再次尝试消费

  • long offset = ...removeMessage(msgs):从 pq 中删除已经消费成功的消息,返回 offset

  • this...getOffsetStore().updateOffset():更新消费者本地该 mq 的消费进度


消费请求

ConsumeRequest 是 ConsumeMessageConcurrentlyService 的内部类,是一个 Runnable 任务对象

成员变量:

  • 分配到该消费任务的消息:

    1
    private final List<MessageExt> msgs;
  • 消息队列:

    1
    2
    private final ProcessQueue processQueue;	// 消息处理队列
    private final MessageQueue messageQueue; // 消息队列

核心方法:

  • run():执行任务

    1
    public void run()
    • if (this.processQueue.isDropped()):条件成立说明该 queue 经过 rbl 算法分配到其他的 consumer
    • MessageListenerConcurrently listener:获取消息监听器
    • ConsumeConcurrentlyContext context:创建消费上下文对象
    • defaultMQPushConsumerImpl.resetRetryAndNamespace():重置重试标记
      • final String groupTopic:获取当前消费者组的重试主题 %RETRY%GroupName
      • for (MessageExt msg : msgs):遍历所有的消息
      • String retryTopic = msg.getProperty(...):原主题,一般消息没有该属性,只有被重复消费的消息才有
      • if (retryTopic != null && groupTopic.equals(...)):条件成立说明该消息是被重复消费的消息
      • msg.setTopic(retryTopic):将被重复消费的消息主题修改回原主题
    • if (ConsumeMessageConcurrentlyService...hasHook()):前置处理
    • boolean hasException = false:消费过程中,是否向外抛出异常
    • MessageAccessor.setConsumeStartTimeStamp():给每条消息设置消费开始时间
    • status = listener.consumeMessage(Collections.unmodifiableList(msgs), context)消费消息
    • if (ConsumeMessageConcurrentlyService...hasHook()):后置处理
    • ...processConsumeResult(status, context, this)处理消费结果

顺序消费

成员属性

ConsumeMessageOrderlyService 负责顺序消费服务

成员变量:

  • 消息监听器:封装处理消息的逻辑,该监听器由开发者实现,并注册到 defaultMQPushConsumer

    1
    private final MessageListenerOrderly messageListener;
  • 消费属性:

    1
    2
    3
    private final BlockingQueue<Runnable> consumeRequestQueue;	// 消费任务队列
    private final String consumerGroup; // 消费者组
    private volatile boolean stopped = false; // 消费停止状态
  • 线程池:

    1
    2
    private final ThreadPoolExecutor consumeExecutor;				// 消费任务线程池
    private final ScheduledExecutorService scheduledExecutorService;// 调度线程池,延迟提交消费任务
  • 队列锁:消费者本地 MQ 锁,确保本地对于需要顺序消费的 MQ 同一时间只有一个任务在执行

    1
    private final MessageQueueLock messageQueueLock = new MessageQueueLock();
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public class MessageQueueLock {
    private ConcurrentMap<MessageQueue, Object> mqLockTable = new ConcurrentHashMap<MessageQueue, Object>();
    // 获取本地队列锁对象
    public Object fetchLockObject(final MessageQueue mq) {
    Object objLock = this.mqLockTable.get(mq);
    if (null == objLock) {
    objLock = new Object();
    Object prevLock = this.mqLockTable.putIfAbsent(mq, objLock);
    if (prevLock != null) {
    objLock = prevLock;
    }
    }
    return objLock;
    }
    }

    已经获取了 Broker 端该 Queue 的独占锁,为什么还要获取本地队列锁对象?(这里我也没太懂,先记录下来,本地多线程?)

    • Broker queue 占用锁的角度是 Client 占用,Client 从 Broker 的某个占用了锁的 queue 拉取下来消息以后,将消息存储到消费者本地的 ProcessQueue 中,快照对象的 consuming 属性置为 true,表示本地的队列正在消费处理中
    • ProcessQueue 调用 takeMessages 方法时会获取下一批待处理的消息,获取不到会修改 consuming = false,本消费任务马上停止。
    • 如果此时 Pull 再次拉取一批当前 ProcessQueue 的 msg,会再次向顺序消费服务提交消费任务,此时需要本地队列锁对象同步本地线程

成员方法
  • start():启动消费服务,DefaultMQPushConsumerImpl 启动时会调用该方法

    1
    public void start()
    • this.scheduledExecutorService.scheduleAtFixedRate():提交锁续约任务,延迟 1 秒执行,周期为 20 秒钟
    • ConsumeMessageOrderlyService.this.lockMQPeriodically()锁续约任务
      • this.defaultMQPushConsumerImpl.getRebalanceImpl().lockAll():对消费者的所有队列进行续约
  • submitConsumeRequest():提交消费任务请求

    1
    2
    3
    4
    5
    6
    7
    8
    // 参数:true 表示创建消费任务并提交,false不创建消费任务,说明消费者本地已经有消费任务在执行了
    public void submitConsumeRequest(...., final boolean dispathToConsume) {
    if (dispathToConsume) {
    // 当前进程内不存在 顺序消费任务,创建新的消费任务,【提交到消费任务线程池】
    ConsumeRequest consumeRequest = new ConsumeRequest(processQueue, messageQueue);
    this.consumeExecutor.submit(consumeRequest);
    }
    }
  • processConsumeResult():消费结果处理

    1
    2
    3
    4
    // 参数1:msgs 本轮循环消费的消息集合    					参数2:status  消费状态
    // 参数3:context 消费上下文 参数4:消费任务
    // 返回值:boolean 决定是否继续循环处理pq内的消息
    public boolean processConsumeResult(final List<MessageExt> msgs, final ConsumeOrderlyStatus status, final ConsumeOrderlyContext context, final ConsumeRequest consumeRequest)
    • if (context.isAutoCommit()) :默认自动提交

    • switch (status):根据消费状态进行不同的处理

    • case SUCCESS:消费成功

      commitOffset = ...commit():调用 pq 提交方法,会将本次循环处理的消息从顺序消费 map 删除,并且返回消息进度

    • case SUSPEND_CURRENT_QUEUE_A_MOMENT:挂起当前队列

      consumeRequest.getProcessQueue().makeMessageToConsumeAgain(msgs)回滚消息

      • for (MessageExt msg : msgs):遍历所有的消息
      • this.consumingMsgOrderlyTreeMap.remove(msg.getQueueOffset()):从顺序消费临时容器中移除
      • this.msgTreeMap.put(msg.getQueueOffset(), msg):添加到消息容器
    • this.submitConsumeRequestLater():再次提交消费任务,1 秒后执行

    • continueConsume = false:设置为 false,外层会退出本次的消费任务

    • this.defaultMQPushConsumerImpl.getOffsetStore().updateOffset(...):更新本地消费进度


消费请求

ConsumeRequest 是 ConsumeMessageOrderlyService 的内部类,是一个 Runnable 任务对象

核心方法:

  • run():执行任务

    1
    public void run()
    • final Object objLock:获取本地锁对象

    • synchronized (objLock):本地队列锁,确保每个 MQ 的消费任务只有一个在执行,确保顺序消费

    • if(.. || (this.processQueue.isLocked() && !this.processQueue.isLockExpired()))):当前队列持有分布式锁,并且锁未过期,持锁时间超过 30 秒算过期

    • final long beginTime:消费开始时间

    • for (boolean continueConsume = true; continueConsume; ):根据是否继续消费的标记判断是否继续

    • final int consumeBatchSize:获取每次循环处理的消息数量,一般是 1

    • List<MessageExt> msgs = this...takeMessages(consumeBatchSize):到处理队列获取一批消息

    • if (!msgs.isEmpty()):获取到了待消费的消息

      final ConsumeOrderlyContext context:创建消费上下文对象

      this.processQueue.getLockConsume().lock()获取 lockConsume 锁,与 RBL 线程同步使用

      status = messageListener.consumeMessage(...):监听器处理消息

      this.processQueue.getLockConsume().unlock()释放 lockConsume 锁

      if (null == status):处理消息状态返回 null,设置状态为挂起当前队列

      continueConsume = ...processConsumeResult():消费结果处理

    • else:获取到的消息是空

      continueConsume = false:结束任务循环

    • else:当前队列未持有分布式锁,或者锁过期

      ConsumeMessageOrderlyService.this.tryLockLaterAndReconsume():重新提交任务,根据是否获取到队列锁,选择延迟 10 毫秒或者 300 毫秒


生产消费

生产流程:

  • 首先获取当前消息主题的发布信息,获取不到去 Namesrv 获取(默认有 TBW102),并将获取的到的路由数据转化为发布数据,创建 MQ 队列,客户端实例同样更新订阅数据,创建 MQ 队列,放入负载均衡服务 topicSubscribeInfoTable 中
  • 然后从发布数据中选择一个 MQ 队列发送消息
  • Broker 端通过 SendMessageProcessor 对发送的消息进行持久化处理,存储到 CommitLog。将重试次数过多的消息加入死信队列,将延迟消息的主题和队列修改为调度主题和调度队列 ID
  • Broker 启动 ScheduleMessageService 服务会为每个延迟级别创建一个延迟任务,让延迟消息得到有效的处理,将到达交付时间的消息修改为原始主题的原始 ID 存入 CommitLog,消费者就可以进行消费了

消费流程:

  • 首先通过负载均衡服务,将分配到当前消费者实例的 MQ 创建 PullRequest,并放入 PullMessageService 的本地阻塞队列内
  • PullMessageService 循环从阻塞队列获取请求对象,发起拉消息请求,并创建 PullCallback 回调对象,将正常拉取的消息提交到消费任务线程池,并设置请求的下一次拉取位点,重新放入阻塞队列,形成闭环
  • 消费任务服务对消费失败的消息进行回退,回退失败的消息会再次提交消费任务重新消费
  • Broker 端对拉取消息的请求进行处理(processRequestCommand),查询成功将消息放入响应体,通过 Netty 写回客户端,当 pullRequest.offset == queue.maxOffset 说明该队列已经没有需要获取的消息,将请求放入长轮询集合等待有新消息
  • PullRequestHoldService 负责长轮询,每 5 秒遍历一次长轮询集合,将满足条件的 PullRequest 再次提交到线程池内处理

Zookeeper

基本介绍

框架特征

Zookeeper 是 Apache Hadoop 项目子项目,为分布式框架提供协调服务,是一个树形目录服务

Zookeeper 是基于观察者模式设计的分布式服务管理框架,负责存储和管理共享数据,接受观察者的注册监控,一旦这些数据的状态发生变化,Zookeeper 会通知观察者

  • Zookeeper 是一个领导者(Leader),多个跟随者(Follower)组成的集群
  • 集群中只要有半数以上节点存活就能正常服务,所以 Zookeeper 适合部署奇数台服务器
  • 全局数据一致,每个 Server 保存一份相同的数据副本,Client 无论连接到哪个 Server,数据都是一致
  • 更新的请求顺序执行,来自同一个 Client 的请求按其发送顺序依次执行
  • 数据更新原子性,一次数据更新要么成功,要么失败
  • 实时性,在一定的时间范围内,Client 能读到最新数据
  • 心跳检测,会定时向各个服务提供者发送一个请求(实际上建立的是一个 Socket 长连接)

参考视频:https://www.bilibili.com/video/BV1to4y1C7gw


应用场景

Zookeeper 提供的主要功能包括:统一命名服务、统一配置管理、统一集群管理、服务器节点动态上下线、软负载均衡、分布式锁等

  • 在分布式环境中,经常对应用/服务进行统一命名,便于识别,例如域名相对于 IP 地址更容易被接收

    1
    2
    /service/www.baidu.com 		# 节点路径
    192.168.1.1 192.168.1.2 # 节点值

    如果在节点中记录每台服务器的访问数,让访问数最少的服务器去处理最新的客户端请求,可以实现负载均衡

    1
    2
    192.168.1.1  10	# 次数
    192.168.1.1 15
  • 配置文件同步可以通过 Zookeeper 实现,将配置信息写入某个 ZNode,其他客户端监视该节点,当节点数据被修改,通知各个客户端服务器

  • 集群环境中,需要实时掌握每个集群节点的状态,可以将这些信息放入 ZNode,通过监控通知的机制实现

  • 实现客户端实时观察服务器上下线的变化,通过心跳检测实现


基本操作

安装搭建

安装步骤:

  • 安装 JDK

  • 拷贝 apache-zookeeper-3.5.7-bin.tar.gz 安装包到 Linux 系统下,并解压到指定目录

  • conf 目录下的配置文件重命名:

    1
    mv zoo_sample.cfg zoo.cfg
  • 修改配置文件:

    1
    2
    3
    vim zoo.cfg
    # 修改内容
    dataDir=/home/seazean/SoftWare/zookeeper-3.5.7/zkData
  • 在对应目录创建 zkData 文件夹:

    1
    mkdir zkData

Zookeeper 中的配置文件 zoo.cfg 中参数含义解读:

  • tickTime = 2000:通信心跳时间,Zookeeper 服务器与客户端心跳时间,单位毫秒
  • initLimit = 10:Leader 与 Follower 初始通信时限,初始连接时能容忍的最多心跳次数
  • syncLimit = 5:Leader 与 Follower 同步通信时限,LF 通信时间超过 syncLimit * tickTime,Leader 认为 Follwer 下线
  • dataDir:保存 Zookeeper 中的数据目录,默认是 tmp目录,容易被 Linux 系统定期删除,所以建议修改
  • clientPort = 2181:客户端连接端口,通常不做修改

操作命令

服务端

Linux 命令:

  • 启动 ZooKeeper 服务:./zkServer.sh start

  • 查看 ZooKeeper 服务:./zkServer.sh status

  • 停止 ZooKeeper 服务:./zkServer.sh stop

  • 重启 ZooKeeper 服务:./zkServer.sh restart

  • 查看进程是否启动:jps


客户端

Linux 命令:

  • 连接 ZooKeeper 服务端:

    1
    2
    ./zkCli.sh					# 直接启动
    ./zkCli.sh –server ip:port # 指定 host 启动

客户端命令:

  • 基础操作:

    1
    2
    quit						# 停止连接
    help # 查看命令帮助
  • 创建命令:**/ 代表根目录**

    1
    2
    3
    4
    create /path value			# 创建节点,value 可选
    create -e /path value # 创建临时节点
    create -s /path value # 创建顺序节点
    create -es /path value # 创建临时顺序节点,比如node10000012 删除12后也会继续从13开始,只会增加
  • 查询命令:

    1
    2
    3
    4
    5
    6
    ls /path					# 显示指定目录下子节点
    ls –s /path # 查询节点详细信息
    ls –w /path # 监听子节点数量的变化
    stat /path # 查看节点状态
    get –s /path # 查询节点详细信息
    get –w /path # 监听节点数据的变化
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    # 属性,分为当前节点的属性和子节点属性
    czxid: 节点被创建的事务ID, 是ZooKeeper中所有修改总的次序,每次修改都有唯一的 zxid,谁小谁先发生
    ctime: 被创建的时间戳
    mzxid: 最后一次被更新的事务ID
    mtime: 最后修改的时间戳
    pzxid: 子节点列表最后一次被更新的事务ID
    cversion: 子节点的变化号,修改次数
    dataversion: 节点的数据变化号,数据的变化次数
    aclversion: 节点的访问控制列表变化号
    ephemeralOwner: 用于临时节点,代表节点拥有者的 session id,如果为持久节点则为0
    dataLength: 节点存储的数据的长度
    numChildren: 当前节点的子节点数量
  • 删除命令:

    1
    2
    delete /path				# 删除节点
    deleteall /path # 递归删除节点

数据结构

ZooKeeper 是一个树形目录服务,类似 Unix 的文件系统,每一个节点都被称为 ZNode,每个 ZNode 默认存储 1MB 的数据,节点上会保存数据和节点信息,每个 ZNode 都可以通过其路径唯一标识

节点可以分为四大类:

  • PERSISTENT:持久化节点
  • EPHEMERAL:临时节点,客户端和服务器端断开连接后,创建的节点删除
  • PERSISTENT_SEQUENTIAL:持久化顺序节点,创建 znode 时设置顺序标识,节点名称后会附加一个值,顺序号是一个单调递增的计数器,由父节点维护
  • EPHEMERAL_SEQUENTIAL:临时顺序节点

注意:在分布式系统中,顺序号可以被用于为所有的事件进行全局排序,这样客户端可以通过顺序号推断事件的顺序


代码实现

添加 Maven 依赖:

1
2
3
4
5
<dependency>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
<version>3.5.7</version>
</dependency>

实现代码:

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
// 参数一:连接地址
// 参数二:会话超时时间
// 参数三:监听器
ZooKeeper zkClient = new ZooKeeper("192.168.3.128:2181", 20000, new Watcher() {
@Override
public void process(WatchedEvent event) {
System.out.println("监听处理函数");
}
});
}

集群介绍

相关概念

Zookeepe 集群三个角色:

  • Leader 领导者:处理客户端事务请求,负责集群内部各服务器的调度

  • Follower 跟随者:处理客户端非事务请求,转发事务请求给 Leader 服务器,参与 Leader 选举投票

  • Observer 观察者:观察集群的最新状态的变化,并将这些状态进行同步;处理非事务性请求,事务性请求会转发给 Leader 服务器进行处理;不会参与任何形式的投票。只提供非事务性的服务,通常用于在不影响集群事务处理能力的前提下,提升集群的非事务处理能力(提高集群读的能力,但是也降低了集群选主的复杂程度)

相关属性:

  • SID:服务器 ID,用来唯一标识一台集群中的机器,和 myid 一致

  • ZXID:事务 ID,用来标识一次服务器状态的变更,在某一时刻集群中每台机器的 ZXID 值不一定完全一致,这和 ZooKeeper 服务器对于客户端更新请求的处理逻辑有关

  • Epoch:每个 Leader 任期的代号,同一轮选举投票过程中的该值是相同的,投完一次票就增加

选举机制:半数机制,超过半数的投票就通过

  • 第一次启动选举规则:投票过半数时,服务器 ID 大的胜出

  • 第二次启动选举规则:

    • EPOCH 大的直接胜出
    • EPOCH 相同,事务 ID 大的胜出(事务 ID 越大,数据越新)
    • 事务 ID 相同,服务器 ID 大的胜出

初次选举

选举过程:

  • 服务器 1 启动,发起一次选举,服务器 1 投自己一票,票数不超过半数,选举无法完成,服务器 1 状态保持为 LOOKING
  • 服务器 2 启动,再发起一次选举,服务器 1 和 2 分别投自己一票并交换选票信息,此时服务器 1 会发现服务器 2 的 SID 比自己投票推举的(服务器 1)大,更改选票为推举服务器 2。投票结果为服务器 1 票数 0 票,服务器 2 票数 2 票,票数不超过半数,选举无法完成,服务器 1、2 状态保持 LOOKING
  • 服务器 3 启动,发起一次选举,此时服务器 1 和 2 都会更改选票为服务器 3,投票结果为服务器 3 票数 3 票,此时服务器 3 的票数已经超过半数,服务器 3 当选 Leader,服务器 1、2 更改状态为 FOLLOWING,服务器 3 更改状态为 LEADING
  • 服务器 4 启动,发起一次选举,此时服务器 1、2、3 已经不是 LOOKING 状态,不会更改选票信息,交换选票信息结果后服务器 3 为 3 票,服务器 4 为 1 票,此时服务器 4 更改选票信息为服务器 3,并更改状态为 FOLLOWING
  • 服务器 5 启动,同 4 一样


再次选举

ZooKeeper 集群中的一台服务器出现以下情况之一时,就会开始进入 Leader 选举:

  • 服务器初始化启动
  • 服务器运行期间无法和 Leader 保持连接

当一台服务器进入 Leader 选举流程时,当前集群可能会处于以下两种状态:

  • 集群中本来就已经存在一个 Leader,服务器试图去选举 Leader 时会被告知当前服务器的 Leader 信息,对于该服务器来说,只需要和 Leader 服务器建立连接,并进行状态同步即可

  • 集群中确实不存在 Leader,假设服务器 3 和 5 出现故障,开始进行 Leader 选举,SID 为 1、2、4 的机器投票情况

    1
    (EPOCH,ZXID,SID): (1, 8, 1), (1, 8, 2), (1, 7, 4)

    根据选举规则,服务器 2 胜出


数据写入

写操作就是事务请求,写入请求直接发送给 Leader 节点:Leader 会先将数据写入自身,同时通知其他 Follower 写入,当集群中有半数以上节点写入完成,Leader 节点就会响应客户端数据写入完成

写入请求直接发送给 Follower 节点:Follower 没有写入权限,会将写请求转发给 Leader,Leader 将数据写入自身,通知其他 Follower 写入,当集群中有半数以上节点写入完成,Leader 会通知 Follower 写入完成,由 Follower 响应客户端数据写入完成


底层协议

Paxos

Paxos 算法:基于消息传递且具有高度容错特性的一致性算法

优点:快速正确的在一个分布式系统中对某个数据值达成一致,并且保证不论发生任何异常,都不会破坏整个系统的一致性

缺陷:在网络复杂的情况下,可能很久无法收敛,甚至陷入活锁的情况


ZAB

算法介绍

ZAB 协议借鉴了 Paxos 算法,是为 Zookeeper 设计的支持崩溃恢复的原子广播协议,基于该协议 Zookeeper 设计为只有一台客户端(Leader)负责处理外部的写事务请求,然后 Leader 将数据同步到其他 Follower 节点

Zab 协议包括两种基本的模式:消息广播、崩溃恢复


消息广播

ZAB 协议针对事务请求的处理过程类似于一个两阶段提交过程:广播事务阶段、广播提交操作

  • 客户端发起写操作请求,Leader 服务器将请求转化为事务 Proposal 提案,同时为 Proposal 分配一个全局的 ID,即 ZXID
  • Leader 服务器为每个 Follower 分配一个单独的队列,将广播的 Proposal 依次放到队列中去,根据 FIFO 策略进行消息发送
  • Follower 接收到 Proposal 后,将其以事务日志的方式写入本地磁盘中,写入成功后向 Leader 反馈一个 ACK 响应消息
  • Leader 接收到超过半数以上 Follower 的 ACK 响应消息后,即认为消息发送成功,可以发送 Commit 消息
  • Leader 向所有 Follower 广播 commit 消息,同时自身也会完成事务提交,Follower 接收到 Commit 后,将上一条事务提交

两阶段提交模型可能因为 Leader 宕机带来数据不一致:

  • Leader 发起一个事务 Proposal 后就宕机,Follower 都没有 Proposal
  • Leader 收到半数 ACK 宕机,没来得及向 Follower 发送 Commit

崩溃恢复

Leader 服务器出现崩溃或者由于网络原因导致 Leader 服务器失去了与过半 Follower的联系,那么就会进入崩溃恢复模式,崩溃恢复主要包括两部分:Leader 选举和数据恢复

Zab 协议崩溃恢复要求满足以下两个要求:

  • 已经被 Leader 提交的提案 Proposal,必须最终被所有的 Follower 服务器正确提交
  • 丢弃已经被 Leader 提出的,但是没有被提交的 Proposal

Zab 协议需要保证选举出来的 Leader 需要满足以下条件:

  • 新选举的 Leader 不能包含未提交的 Proposal,即新 Leader 必须都是已经提交了 Proposal 的 Follower 节点
  • 新选举的 Leader 节点含有最大的 ZXID,可以避免 Leader 服务器检查 Proposal 的提交和丢弃工作

数据恢复阶段:

  • 完成 Leader 选举后,在正式开始工作之前(接收事务请求提出新的 Proposal),Leader 服务器会首先确认事务日志中的所有 Proposal 是否已经被集群中过半的服务器 Commit
  • Leader 服务器需要确保所有的 Follower 服务器能够接收到每一条事务的 Proposal,并且能将所有已经提交的事务 Proposal 应用到内存数据中,所以只有当 Follower 将所有尚未同步的事务 Proposal 都从 Leader 服务器上同步,并且应用到内存数据后,Leader 才会把该 Follower 加入到真正可用的 Follower 列表中

异常处理

Zab 的事务编号 zxid 设计:

  • zxid 是一个 64 位的数字,低 32 位是一个简单的单增计数器,针对客户端每一个事务请求,Leader 在产生新的 Proposal 事务时,都会对该计数器加 1,而高 32 位则代表了 Leader 周期的 epoch 编号
  • epoch 为当前集群所处的代或者周期,每次 Leader 变更后都会在 epoch 的基础上加 1,Follower 只服从 epoch 最高的 Leader 命令,所以旧的 Leader 崩溃恢复之后,其他 Follower 就不会继续追随
  • 每次选举产生一个新的 Leader,就会从新 Leader 服务器上取出本地事务日志中最大编号 Proposal 的 zxid,从 zxid 中解析得到对应的 epoch 编号,然后再对其加 1 后作为新的 epoch 值,并将低 32 位数字归零,由 0 开始重新生成 zxid

Zab 协议通过 epoch 编号来区分 Leader 变化周期,能够有效避免不同的 Leader 错误的使用了相同的 zxid 编号提出了不一样的 Proposal 的异常情况

Zab 数据同步过程:数据同步阶段要以 Leader 服务器为准

  • 一个包含了上个 Leader 周期中尚未提交过的事务 Proposal 的服务器启动时,这台机器加入集群中会以 Follower 角色连上 Leader
  • Leader 会根据自己服务器上最后提交的 Proposal 和 Follower 服务器的 Proposal 进行比对,让 Follower 进行一个回退或者前进操作,到一个已经被集群中过半机器 Commit 的最新 Proposal(源码解析部分详解)

CAP

CAP 理论指的是在一个分布式系统中,Consistency(一致性)、Availability(可用性)、Partition Tolerance(分区容错性)不能同时成立,ZooKeeper 保证的是 CP

  • ZooKeeper 不能保证每次服务请求的可用性,在极端环境下可能会丢弃一些请求,消费者程序需要重新请求才能获得结果
  • 进行 Leader 选举时集群都是不可用

CAP 三个基本需求,因为 P 是必须的,因此分布式系统选择就在 CP 或者 AP 中:

  • 一致性:指数据在多个副本之间是否能够保持数据一致的特性,当一个系统在数据一致的状态下执行更新操作后,也能保证系统的数据仍然处于一致的状态
  • 可用性:指系统提供的服务必须一直处于可用的状态,对于用户的每一个操作请求总是能够在有限的时间内返回结果
  • 分区容错性:分布式系统在遇到任何网络分区故障时,仍然能够保证对外提供服务,不会宕机,除非是整个网络环境都发生了故障

监听机制

实现原理

ZooKeeper 中引入了 Watcher 机制来实现了发布/订阅功能,客户端注册监听目录节点,在特定事件触发时,ZooKeeper 会通知所有关注该事件的客户端,保证 ZooKeeper 保存的任何的数据的任何改变都能快速的响应到监听应用程序

监听命令:只能生效一次,接收一次通知,再次监听需要重新注册

1
2
ls –w /path					# 监听【子节点数量】的变化
get –w /path # 监听【节点数据】的变化

工作流程:

  • 在主线程中创建 Zookeeper 客户端,这时就会创建两个线程,一个负责网络连接通信(connet),一个负责监听(listener)
  • 通过 connect 线程将注册的监听事件发送给 Zookeeper
  • 在 Zookeeper 的注册监听器列表中将注册的监听事件添加到列表
  • Zookeeper 监听到有数据或路径变化,将消息发送给 listener 线程
  • listener 线程内部调用 process() 方法

Curator 框架引入了 Cache 来实现对 ZooKeeper 服务端事件的监听,三种 Watcher:

  • NodeCache:只是监听某一个特定的节点
  • PathChildrenCache:监控一个 ZNode 的子节点
  • TreeCache:可以监控整个树上的所有节点,类似于 PathChildrenCache 和 NodeCache 的组合

监听案例

整体架构

客户端实时监听服务器动态上下线


代码实现

客户端:先启动客户端进行监听

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class DistributeClient {
private String connectString = "192.168.3.128:2181";
private int sessionTimeout = 20000;
private ZooKeeper zk;

public static void main(String[] args) throws Exception {
DistributeClient client = new DistributeClient();

// 1 获取zk连接
client.getConnect();

// 2 监听/servers下面子节点的增加和删除
client.getServerList();

// 3 业务逻辑
client.business();
}

private void business() throws InterruptedException {
Thread.sleep(Long.MAX_VALUE);
}

private void getServerList() throws KeeperException, InterruptedException {
ArrayList<String> servers = new ArrayList<>();
// 获取所有子节点,true 代表触发监听操作
List<String> children = zk.getChildren("/servers", true);

for (String child : children) {
// 获取子节点的数据
byte[] data = zk.getData("/servers/" + child, false, null);
servers.add(new String(data));
}
System.out.println(servers);
}

private void getConnect() throws IOException {
zk = new ZooKeeper(connectString, sessionTimeout, new Watcher() {
@Override
public void process(WatchedEvent event) {
getServerList();
}
});
}
}

服务端:启动时需要 Program arguments

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class DistributeServer {
private String connectString = "192.168.3.128:2181";
private int sessionTimeout = 20000;
private ZooKeeper zk;

public static void main(String[] args) throws Exception {
DistributeServer server = new DistributeServer();

// 1 获取 zookeeper 连接
server.getConnect();

// 2 注册服务器到 zk 集群,注意参数
server.register(args[0]);

// 3 启动业务逻辑
server.business();
}

private void business() throws InterruptedException {
Thread.sleep(Long.MAX_VALUE);
}

private void register(String hostname) throws KeeperException, InterruptedException {
// OPEN_ACL_UNSAFE: ACL 开放
// EPHEMERAL_SEQUENTIAL: 临时顺序节点
String create = zk.create("/servers/" + hostname, hostname.getBytes(),
ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
System.out.println(hostname + " is online");
}

private void getConnect() throws IOException {
zk = new ZooKeeper(connectString, sessionTimeout, new Watcher() {
@Override
public void process(WatchedEvent event) {
}
});
}
}

分布式锁

实现原理

分布式锁可以实现在分布式系统中多个进程有序的访问该临界资源,多个进程之间不会相互干扰

核心思想:当客户端要获取锁,则创建节点,使用完锁,则删除该节点

  1. 客户端获取锁时,在 /locks 节点下创建临时顺序节点

    • 使用临时节点是为了防止当服务器或客户端宕机以后节点无法删除(持久节点),导致锁无法释放
    • 使用顺序节点是为了系统自动编号排序,找最小的节点,防止客户端饥饿现象,保证公平
  2. 获取 /locks 目录的所有子节点,判断自己的子节点序号是否最小,成立则客户端获取到锁,使用完锁后将该节点删除

  3. 反之客户端需要找到比自己小的节点,对其注册事件监听器,监听删除事件

  4. 客户端的 Watcher 收到删除事件通知,就会重新判断当前节点是否是子节点中序号最小,如果是则获取到了锁, 如果不是则重复以上步骤继续获取到比自己小的一个节点并注册监听


Curator

Curator 实现分布式锁 API,在 Curator 中有五种锁方案:

  • InterProcessSemaphoreMutex:分布式排它锁(非可重入锁)

  • InterProcessMutex:分布式可重入排它锁

  • InterProcessReadWriteLock:分布式读写锁

  • InterProcessMultiLock:将多个锁作为单个实体管理的容器

  • InterProcessSemaphoreV2:共享信号量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class CuratorLock {

public static CuratorFramework getCuratorFramework() {
// 重试策略对象
ExponentialBackoffRetry policy = new ExponentialBackoffRetry(3000, 3);
// 构建客户端
CuratorFramework client = CuratorFrameworkFactory.builder()
.connectString("192.168.3.128:2181")
.connectionTimeoutMs(2000) // 连接超时时间
.sessionTimeoutMs(20000) // 会话超时时间 单位ms
.retryPolicy(policy) // 重试策略
.build();

// 启动客户端
client.start();
System.out.println("zookeeper 启动成功");
return client;
}

public static void main(String[] args) {
// 创建分布式锁1
InterProcessMutex lock1 = new InterProcessMutex(getCuratorFramework(), "/locks");

// 创建分布式锁2
InterProcessMutex lock2 = new InterProcessMutex(getCuratorFramework(), "/locks");

new Thread(new Runnable() {
@Override
public void run() {
lock1.acquire();
System.out.println("线程1 获取到锁");

Thread.sleep(5 * 1000);

lock1.release();
System.out.println("线程1 释放锁");
}
}).start();

new Thread(new Runnable() {
@Override
public void run() {
lock2.acquire();
System.out.println("线程2 获取到锁");

Thread.sleep(5 * 1000);

lock2.release();
System.out.println("线程2 释放锁");

}
}).start();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-framework</artifactId>
<version>4.3.0</version>
</dependency>
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>4.3.0</version>
</dependency>
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-client</artifactId>
<version>4.3.0</version>

源码解析

服务端

服务端程序的入口 QuorumPeerMain

1
2
3
4
public static void main(String[] args) {
QuorumPeerMain main = new QuorumPeerMain();
main.initializeAndRun(args);
}

initializeAndRun 的工作:

  • 解析启动参数

  • 提交周期任务,定时删除过期的快照

  • 初始化通信模型,默认是 NIO 通信

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // QuorumPeerMain#runFromConfig
    public void runFromConfig(QuorumPeerConfig config) {
    // 通信信组件初始化,默认是 NIO 通信
    ServerCnxnFactory cnxnFactory = ServerCnxnFactory.createFactory();
    // 初始化NIO 服务端socket,绑定2181 端口,可以接收客户端请求
    cnxnFactory.configure(config.getClientPortAddress(), config.getMaxClientCnxns(), false);
    // 启动 zk
    quorumPeer.start();
    }
  • 启动 zookeeper

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    // QuorumPeer#start
    public synchronized void start() {
    if (!getView().containsKey(myid)) {
    throw new RuntimeException("My id " + myid + " not in the peer list");
    }
    // 冷启动数据恢复,将快照中数据恢复到 DataTree
    loadDataBase();
    // 启动通信工厂实例对象
    startServerCnxnFactory();
    try {
    adminServer.start();
    } catch (AdminServerException e) {
    LOG.warn("Problem starting AdminServer", e);
    System.out.println(e);
    }
    // 准备选举环境
    startLeaderElection();
    // 执行选举
    super.start();
    }

选举机制

环境准备

QuorumPeer#startLeaderElection 初始化选举环境:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
synchronized public void startLeaderElection() {
try {
// Looking 状态,需要选举
if (getPeerState() == ServerState.LOOKING) {
// 选票组件: myid (serverid), zxid, epoch
// 开始选票时,serverid 是自己,【先投自己】
currentVote = new Vote(myid, getLastLoggedZxid(), getCurrentEpoch());
}
}
if (electionType == 0) {
try {
udpSocket = new DatagramSocket(getQuorumAddress().getPort());
// 响应投票结果线程
responder = new ResponderThread();
responder.start();
} catch (SocketException e) {
throw new RuntimeException(e);
}
}
// 创建选举算法实例
this.electionAlg = createElectionAlgorithm(electionType);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// zk总的发送和接收队列准备好
protected Election createElectionAlgorithm(int electionAlgorithm){
// 负责选举过程中的所有网络通信,创建各种队列和集合
QuorumCnxManager qcm = createCnxnManager();
QuorumCnxManager.Listener listener = qcm.listener;
if(listener != null){
// 启动监听线程, 调用 client = ss.accept()阻塞,等待处理请求
listener.start();
// 准备好发送和接收队列准备
FastLeaderElection fle = new FastLeaderElection(this, qcm);
// 启动选举线程,【WorkerSender 和 WorkerReceiver】
fle.start();
le = fle;
}
}

选举源码

当 Zookeeper 启动后,首先都是 Looking 状态,通过选举让其中一台服务器成为 Leader

执行 super.start() 相当于执行 QuorumPeer#run() 方法

1
2
3
4
5
public void run() {
case LOOKING:
// 进行选举,选举结束返回最终成为 Leader 胜选的那张选票
setCurrentVote(makeLEStrategy().lookForLeader());
}

FastLeaderElection 类:

  • lookForLeader:选举

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    public Vote lookForLeader() {
    // 正常启动中其他服务器都会向我发送一个投票,保存每个服务器的最新合法有效的投票
    HashMap<Long, Vote> recvset = new HashMap<Long, Vote>();
    // 存储合法选举之外的投票结果
    HashMap<Long, Vote> outofelection = new HashMap<Long, Vote>();
    // 一次选举的最大等待时间,默认值是0.2s
    int notTimeout = finalizeWait;
    // 每发起一轮选举,logicalclock++,在没有合法的epoch 数据之前,都使用逻辑时钟代替
    synchronized(this){
    // 更新逻辑时钟,每进行一次选举,都需要更新逻辑时钟
    logicalclock.incrementAndGet();
    // 更新选票(serverid, zxid, epoch)
    updateProposal(getInitId(), getInitLastLoggedZxid(), getPeerEpoch());
    }
    // 广播选票,把自己的选票发给其他服务器
    sendNotifications();
    // 一轮一轮的选举直到选举成功
    while ((self.getPeerState() == ServerState.LOOKING) && (!stop)){ }
    }
  • sendNotifications:广播选票

    1
    2
    3
    4
    5
    6
    7
    8
    9
    private void sendNotifications() {
    // 遍历投票参与者,给每台服务器发送选票
    for (long sid : self.getCurrentAndNextConfigVoters()) {
    // 创建发送选票
    ToSend notmsg = new ToSend(...);
    // 把发送选票放入发送队列
    sendqueue.offer(notmsg);
    }
    }

FastLeaderElection 中有 WorkerSender 线程:

  • ToSend m = sendqueue.poll(3000, TimeUnit.MILLISECONDS)阻塞获取要发送的选票

  • process(m):处理要发送的选票

    manager.toSend(m.sid, requestBuffer):发送选票

    • if (this.mySid == sid):如果消息的接收者 sid 是自己,直接进入自己的 RecvQueue(自己投自己)

    • else:如果接收者是其他服务器,创建对应的发送队列或者复用已经存在的发送队列,把消息放入该队列

    • connectOne(sid):建立连接

      • sock.connect(electionAddr, cnxTO):建立与 sid 服务器的连接

      • initiateConnection(sock, sid):初始化连接

        startConnection(sock, sid):创建并启动发送器线程和接收器线程

        • dout = new DataOutputStream(buf)获取 Socket 输出流,向服务器发送数据
        • din = new DataInputStream(new BIS(sock.getInputStream()))):通过输入流读取对方发送过来的选票
        • if (sid > self.getId()):接收者 sid 比我的大,没有资格给对方发送连接请求的,直接关闭自己的客户端
        • SendWorker sw:初始化发送器,并启动发送器线程,线程 run 方法
          • while (running && !shutdown && sock != null):连接没有断开就一直运行
          • ByteBuffer b = pollSendQueue():从发送队列 SendQueue 中获取发送消息
          • lastMessageSent.put(sid, b):更新对于 sid 这台服务器的最近一条消息
          • send(b)执行发送
        • RecvWorker rw:初始化接收器,并启动接收器线程
          • din.readFully(msgArray, 0, length):输入流接收消息
          • addToRecvQueue(new Message(messagg, sid)):将消息放入接收消息 recvQueue 队列

FastLeaderElection 中有 WorkerReceiver 线程

  • response = manager.pollRecvQueue():从 RecvQueue 中阻塞获取出选举投票消息(其他服务器发送过来的)

状态同步

选举结束后,每个节点都需要根据角色更新自己的状态,Leader 更新状态为 Leader,其他节点更新状态为 Follower,整体流程:

  • Follower 需要让 Leader 知道自己的状态 (sid, epoch, zxid)
  • Leader 接收到信息,根据信息构建新的 epoch,要返回对应的信息给 Follower,Follower 更新自己的 epoch
  • Leader 需要根据 Follower 的状态,确定何种方式的数据同步 DIFF、TRUNC、SNAP,就是要以 Leader 服务器数据为准
    • DIFF:Leader 提交的 zxid 比 Follower 的 zxid 大,发送 Proposal 给 Follower 提交执行
    • TRUNC:Follower 的 zxid 比leader 的 zxid 大,Follower 要进行回滚
    • SNAP:Follower 没有任何数据,直接全量同步
  • 执行数据同步,当 Leader 接收到超过半数 Follower 的 Ack 之后,进入正常工作状态,集群启动完成

核心函数解析:

  • Leader 更新状态入口:Leader.lead()
    • zk.loadData():恢复数据到内存
    • cnxAcceptor = new LearnerCnxAcceptor():启动通信组件
      • s = ss.accept():等待其他 Follower 节点向 Leader 节点发送同步状态
      • LearnerHandler fh :接收到 Follower 的请求,就创建 LearnerHandler 对象
      • fh.start():启动线程,通过 switch-case 语法判断接收的命令,执行相应的操作
  • Follower 更新状态入口:Follower.followerLeader()
    • QuorumServer leaderServer = findLeader():查找 Leader
    • connectToLeader(addr, hostname) :与 Leader 建立连接
    • long newEpochZxid = registerWithLeader(Leader.FOLLOWERINFO):向 Leader 注册

主从工作

Leader:主服务的工作流程

Follower:从服务的工作流程,核心函数为 Follower#followLeader()

  • readPacket(qp):读取信息

  • processPacket(qp):处理信息

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    protected void processPacket(QuorumPacket qp) throws Exception{
    switch (qp.getType()) {
    case Leader.PING:
    break;
    case Leader.PROPOSAL:
    break;
    case Leader.COMMIT:
    break;
    case Leader.COMMITANDACTIVATE:
    break;
    case Leader.UPTODATE:
    break;
    case Leader.REVALIDATE:
    break;
    case Leader.SYNC:
    break;
    default:
    break;
    }
    }

客户端

1
2
3
4
5
title: View
date: 2022-01-01 00:00:00
tags: View
categories: View
comment

HTML

HTML入门

概述

HTML(超文本标记语言—HyperText Markup Language)是构成 Web 世界的基础,是一种用来告知浏览器如何组织页面的标记语言

  • 超文本 Hypertext,是指连接单个或者多个网站间的网页的链接。通过链接,就能访问互联网中的内容

  • 标记 Markup ,是用来注明文本,图片等内容,以便于在浏览器中显示,例如 <head><body>

网页的构成

  • HTML:通常用来定义网页内容的含义和基本结构
  • CSS:通常用来描述网页的表现与展示效果
  • JavaScript:通常用来执行网页的功能与行为

参考视频:https://www.bilibili.com/video/BV1Qf4y1T7Hx


组成

标签

HTML 页面由一系列的元素(elements) 组成,而元素是使用标签创建的

一对标签(tags)可以设置一段文字样式,添加一张图片或者添加超链接等等

在 HTML 中,<h1> 标签表示标题,我们可以使用开始标签结束标签包围文本内容,这样其中的内容就以标题的形式显示

1
2
<h1>开始学习JavaWeb</h1>
<h2>二级标题</h2>

属性

HTML 标签可以拥有属性

  • 属性是属于标签的,修饰标签,让标签有更多的效果
  • 属性一般定义在起始标签里面
  • 属性一般以属性=属性值的形式出现
  • 属性值一般用 '' 或者 "" 括起来。 不加引号也是可以的(不建议使用)。比如:name=’value’
1
<h1 align="center">开始学习JavaWeb</h1>

在 HTML 标签中,align 属性表示水平对齐方式,我们可以赋值为 center 表示 居中


结构

HTML结构

文档结构介绍:

  • 文档声明:用于声明当前 HTML 的版本,这里的<!DOCTYPE html>是 HTML5 的声明
  • html 根标签:除文档声明以外,其它内容全部要放在根标签 html 内部
  • 文档头部配置:head 标签,是当前页面的配置信息,外部引入文件, 例如网页标签、字符集等
    • <meta charset="utf-8">:这个标签是页面的元数据信息,设置文档使用 utf-8 字符集编码
    • <title>:这个标签定义文档标题,位置出现在浏览器标签。在收藏页面时,它可用来描述页面
  • 文档显示内容:body 标签,里边的内容会显示到浏览器页面上

HTML语法

注释方式

将一段 HTML 中的内容置为注释,你需要将其用特殊的记号 包括起来

1
2
3
<p>我在注释外!</p>

<!-- <p>我在注释内!</p> -->

基本元素

空元素

一些元素只有一个标签,叫做空元素。它是在开始标签中进行关闭的。

1
2
第一行文档<br/> 
第二行文档<br/>

嵌套元素

把元素放到其它元素之中——这被称作嵌套。

1
<h2><u>二级标题</u></h2>

块元素

在HTML中有两种重要元素类别,块级元素和内联元素

  • 块级元素:

    独占一行。块级元素(block)在页面中以块的形式展现。相对于其前面的内容它会出现在新的一行,其后的内容也会被挤到下一行展现。比如<p><hr><li><div>等。

  • 行内元素

    行内显示。行内元素不会导致换行。通常出现在块级元素中并环绕文档内容的一小部分,而不是一整个段落或者一组内容。比如<b><a><i><span> 等。

注意:一个块级元素不会被嵌套进行内元素中,但可以嵌套在其它块级元素中。

常用的两个标签:(重要

  • <div> 是一个通用的内容容器,并没有任何特殊语义。它可以被用来对其它元素进行分组,一般用于样式化相关的需求。它是一个块级元素
  • 属性:id、style、class
  • <span> 是短语内容的通用行内容器,并没有任何特殊语义。它可以被用来编组元素以达到某种样式。它是一个行内元素

基本属性

标签属性,主要用于拓展标签。属性包含元素的额外信息,这些信息不会出现在实际的内容中。但是可以改变标签的一些行为或者提供数据,属性总是以name = value"的格式展现。

  • 属性名:同一个标签中,属性名不得重复。

  • 大小写:属性和属性值对大小写不敏感。不过W3C标准中,推荐使用小写的属性/属性值。

  • 引号:双引号是最常用的,不过使用单引号也没有问题。

  • 常用属性:

    属性名 作用
    class 定义元素类名,用来选择和访问特定的元素
    id 定义元素唯一标识符,在整个文档中必须是唯一的
    name 定义元素名称,可以用于提交服务器的表单字段
    value 定义在元素内显示的默认值
    style 定义CSS样式,这些样式会覆盖之前设置的样式

特殊字符

在HTML中,字符 <, >,",'& 是特殊字符

原义字符 等价字符引用
< &lt;
> &gt;
&quot;
&apos;
& &amp;
空格 &nbsp;

文本标签

使用文本内容标签设置文字基本样式

标签名 作用
p 表示文本的一个段落
h 表示文档标题,<h1>–<h6> ,呈现了六个不同的级别的标题,<h1> 级别最高,而 <h6> 级别最低
hr 表示段落级元素之间的主题转换,一般显示为水平线
li 表示列表里的条目。(常用在ul ol 中)
ul 表示一个无序列表,可含多个元素,无编号显示。
ol 表示一个有序列表,通常渲染为有带编号的列表
em 表示文本着重,一般用斜体显示
strong 表示文本重要,一般用粗体显示
font 表示字体,可以设置样式(已过时)
i 表示斜体
b 表示加粗文本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>文本标签演示</title>
</head>
<body>
<!--段落标签:<p>-->
<p>这些年</p>
<p>支付宝的诞生就是为了解决淘宝网的客户们的买卖问题</p>

<!-- 标题标签:<h1> ~ <h6> -->
<h1>一级标题</h1>
<h2>二级标题</h2>
<h3>三级标题</h3>
<h4>四级标题</h4>
<h5>五级标题</h5>
<h6>六级标题</h6>

<!--水平线标签:<hr/>
属性:
size-大小
color-颜色
-->
<hr size="4" color="red"/>

<!--
无序列表:<ul>
属性:type-列表样式(disc实心圆、circle空心圆、square实心方块)
列表项:<li>
-->
<ul type="circle">
<li>javaEE</li>
<li>HTML</li>
</ul>

<!--
有序列表:<ol>
属性:type-列表样式(1数字、A或a字母、I或i罗马字符) start-起始位置
列表项:<li>
-->
<ol type="1" start="10">
<li>传智播客</li>
<li>黑马程序员</li>
</ol>

<!--
斜体标签:<i> <em>
-->
<i>我倾斜了</i>
<em>我倾斜了</em>
<br/>

<!--
加粗标签:<strong> <b>
-->
<strong>加粗文本</strong>
<b>加粗文本</b>
<br/>
<!--
文字标签:<font>
属性:
size-大小
color-颜色
-->
<font size="5" color="yellow">这是一段文字</font>
</body>
</html>

效果如下


图片标签

img标签中的img其实是英文image的缩写, img标签的作用, 就是告诉浏览器我们需要显示一张图片

1
<img src="../img/b.jpg" width="400px" height="200px" alt="" title=""/>
属性名 作用
src 图片路径
title 鼠标悬停(hover)时显示文本。
alt 图片描述,图形不显示时的替换文本。
height 图像的高度。
width 图像的宽度。

超链接

超链接标签的作用: 就是用于控制页面与页面(服务器资源)之间跳转的

1
2
3
4
<a href="指定需要跳转的目标路径" target="打开的方式">需要展现给用户的内容</a>
target属性取值:
_blank:新起页面
_self:当前页面(默认)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>超链接标签演示</title>
<style>
a{
/*去掉超链接的下划线*/
text-decoration: none;
/*超链接的颜色*/
color: black;
}

/*鼠标悬浮的样式控制*/
a:hover{
color: red;
}
</style>
</head>
<body>
<!--
超链接标签:<a>
属性:
href-跳转的地址
target-跳转的方式(_self当前页面、_blank新标签页)
-->
<a href="01案例二:样式演示.html" target="_blank">点我跳转到样式演示</a> <br/>
<a href="http://www.itcast.cn" target="_blank">传智播客</a> <br/>
<a href="http://www.itheima.com" target="_self">黑马程序员</a> <br/>
<a href="http://www.itheima.com" target="_blank"><img src="../img/itheima.png" width="150px" height="50px"/></a>
</body>
</html>

效果图:


表单标签

基本介绍

form 表示表单,是用来收集用户输入信息并向 Web 服务器提交的一个容器

1
2
3
<form >
//表单元素
</form>
属性名 作用
action 处理此表单信息的Web服务器的URL地址
method 提交此表单信息到Web服务器的方式,可能的值有get和post,默认为get
autocomplete 自动补全,指示表单元素是否能够拥有一个默认值,配合input标签使用

get与post区别:

  • post:指的是 HTTP POST 方法;表单数据会包含在表单体内然后发送给服务器。

  • get:指的是 HTTP GET 方法;表单数据会附加在 action 属性的URI中,并以 ‘?’ 作为分隔符,然后这样得到的 URI 再发送给服务器。

地址栏可见 数据安全 数据大小
GET 可见 不安全 有限制(取决于浏览器)
POST 不可见 相对安全 无限制

表单元素

标签名 作用 备注
label 表单元素的说明,配合表单元素使用 for属性值为相关表单元素id属性值
input 表单中输入控件,多种输入类型,用于接受来自用户数据 type属性值决定输入类型
button 页面中可点击的按钮,可以配合表单进行提交 type属性值决定按钮类型
select 表单的控件,下拉选项菜单 与option配合实用
optgroup option的分组标签 与option配合实用
option select的子标签,表示一个选项
textarea 表示多行纯文本编辑控件
fieldset 用来对表单中的控制元素进行分组(也包括 label 元素)
legend 用于表示它的fieldset内容的标题。 fieldset 的子元素

按键控件

button标签:表示按钮

  • type属性:表示按钮类型,submit值为提交按钮。
属性值 作用 备注
button 无行为按钮,用于结合JavaScript实现自定义动态效果 <input type="submit"/>
submit 提交按钮,用于提交表单数据到服务器。 <input type="submit"/>
reset 重置按钮,用于将表单中内容恢复为默认值。 <input type="reset"/>

输入控件

基本介绍
  • label标签:表单的说明。

    • for属性值:匹配input标签的id属性值
  • input标签:输入控件。

    属性:

    • type:表示输入类型,text值为普通文本框
    • id:表示标签唯一标识
    • name:表示标签名称,提交服务器的标识
    • value:表示标签的默认数据值
    • placeholder:默认的提示信息,仅适用于当type 属性为text, search, tel, url or email时;
    • required:是否必须为该元素填充值,当type属性是hidden,image或者button类型时不可使用
    • readonly:是否只读,可以让用户不修改这个输入框的值,就使用value属性设置默认值
    • disabled:是否可用,如果某个输入框有disabled那么它的数据不能提交到服务器通常是使用在有的页面中,让一些按钮不能点击
    • autocomplete:自动补全,规定表单或输入字段是否应该自动完成。当自动完成开启,浏览器会基于用户之前的输入值自动填写值。可以设置指定的字段为off,关闭自动补全
1
2
3
4
5
6
7
8
9
10
<body>
<form action="#" method="get" autocomplete="off">
<label for="username">用户名:</label>
<input type="text" id="username" name="username" value="" placeholder=" 请在此处输入用户名" required/>
<button type="submit">提交</button>
<button type="reset">重置</button>
<button type="button">按钮</button>
</form>
</body>
</html>

效果图:

表单项标签
n-v属性
属性名 作用
name <input>的名字,在提交整个表单数据时,可以用于区分属于不同<input>的值
value 这个<input>元素当前的值,允许用户通过页面输入

使用方式:以name属性值作为键,value属性值作为值,构成键值对提交到服务器,多个键值对浏览器使用&进行分隔。

type属性
属性值 作用 备注
text 单行文本字段
password 单行文本字段,值被遮盖
email 用于编辑 e-mail 的字段,可以对e-mail地址进行简单校验
radio 单选按钮。 1. 在同一个”单选按钮组“中,所有单选按钮的 name 属性使用同一个值;一个单选按钮组中是,同一时间只有一个单选按钮可以被选择。 2. 必须使用 value 属性定义此控件被提交时的值。 3. 使用checked 必须指示控件是否缺省被选择。
checkbox 复选框。 1. 必须使用 value 属性定义此控件被提交时的值。 2. 使用 checked 属性指示控件是否被选择。 3. 选中多个值时,所有的值会构成一个数组而提交到Web服务器
date HTML5 用于输入日期的控件 年,月,日,不包括时间
time HTML5 用于输入时间的控件 不含时区
datetime-local HTML5 用于输入日期时间的控件 不包含时区
number HTML5 用于输入浮点数的控件
range HTML5 用于输入不精确值控件 max-规定最大值min-规定最小值 step-规定步进值 value-规定默认值
search HTML5 用于输入搜索字符串的单行文本字段 可以点击x清除内容
tel HTML5 用于输入电话号码的控件
url HTML5 用于编辑URL的字段 可以校验URL地址格式
file 此控件可以让用户选择文件,用于文件上传。 使用 accept 属性可以定义控件可以选择的文件类型。
hidden 此控件用户在页面上不可见,但它的值会被提交到服务器,用于传递隐藏值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>type属性演示</title>
</head>
<body>
<form action="#" method="get" autocomplete="off">
<label for="username">用户名:</label>
<input type="text" id="username" name="username"/> <br/>

<label for="password">密码:</label>
<input type="password" id="password" name="password"/> <br/>

<label for="email">邮箱:</label>
<input type="email" id="email" name="email"/> <br/>

<label for="gender">性别:</label>
<input type="radio" id="gender" name="gender" value="men"/>
<input type="radio" name="gender" value="women"/>
<input type="radio" name="gender" value="other"/>其他<br/>

<label for="hobby">爱好:</label>
<input type="checkbox" id="hobby" name="hobby" value="music" checked/>音乐
<input type="checkbox" name="hobby" value="game"/>游戏 <br/>

<label for="birthday">生日:</label>
<input type="date" id="birthday" name="birthday"/> <br/>

<label for="time">当前时间:</label>
<input type="time" id="time" name="time"/> <br/>

<label for="insert">注册时间:</label>
<input type="datetime-local" id="insert" name="insert"/> <br/>

<label for="age">年龄:</label>
<input type="number" id="age" name="age"/> <br/>

<label for="range">心情值(1~10):</label>
<input type="range" id="range" name="range" min="1" max="10" step="1"/> <br/>

<label for="search">可全部清除文本:</label>
<input type="search" id="search" name="search"/> <br/>

<label for="tel">电话:</label>
<input type="tel" id="tel" name="tel"/> <br/>

<label for="url">个人网站:</label>
<input type="url" id="url" name="url"/> <br/>

<label for="file">文件上传:</label>
<input type="file" id="file" name="file"/> <br/>

<label for="hidden">隐藏信息:</label>
<input type="hidden" id="hidden" name="hidden" value="itheima"/> <br/>

<button type="submit">提交</button>
<button type="reset">重置</button>
</form>
</body>
</html>


选择控件

下拉列表标签

1
2
3
<select name="">
<option value="">显示的内容</option>
</select>
  • option:选择菜单的选项

  • optgroup:列表项分组标签
    属性:label设置分组名称

文本域控件

1
<textarea name="textarea" rows="10" cols="50">Write something here</textarea>

属性:

  • name-标签名称

  • rows-行数

  • cols-列数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<body>
<form action="#" method="get" autocomplete="off">
所在城市:<select name="city">
<option>---请选择城市---</option>
<optgroup label="直辖市">
<option>北京</option>
<option>上海</option>
</optgroup>
<optgroup label="省会市">
<option>杭州</option>
<option>武汉</option>
</optgroup>
</select>
<br/>
个人介绍:<textarea name="desc" rows="5" cols="20"></textarea>
</form>
</body>


分组控件

1
2
3
4
5
6
7
8
9
<form action="#" method="post">
<fieldset>
<legend>是否同意</legend>
<input type="radio" id="radio_y" name="agree" value="y">
<label for="radio_y">同意</label>
<input type="radio" id="radio_n" name="agree" value="n">
<label for="radio_n">不同意</label>
</fieldset>
</form>
是否同意

表格标签

基本属性

<table> , 表示表格标签,表格是数据单元的行和列的两维表

  • tr:table row,表示表中单元的行
  • td:table data,表示表中一个单元格
  • th:table header,表格单元格的表头,通常字体样式加粗居中

代码展示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<table>
<tr>
<th>First name</th>
<th>Last name</th>
</tr>
<tr>
<td>John</td>
<td>Doe</td>
</tr>
<tr>
<td>Jane</td>
<td>Doe</td>
</tr>
</table>

效果图:

First name Last name
John Doe
Jane Doe

跨行跨列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<table width="400px" border="1px" align="center">
<thead>
<tr>
<th>姓名</th>
<th>性别</th>
<th>年龄</th>
<th>数学</th>
<th>语文</th>
</tr>
</thead>

<tbody>
<tr align="center">
<td>张三</td>
<td rowspan="2"></td>
<td>23</td>
<td colspan="2">90</td>
<!--<td>90</td>-->
</tr>

<tr align="center">
<td>李四</td>
<!--<td>男</td>-->
<td>24</td>
<td>95</td>
<td>98</td>
</tr>
</tbody>

<tfoot>
<tr>
<td colspan="4">总分数:</td>
<td>373</td>
</tr>
</tfoot>
</table>

效果图:


表格结构

标签名 作用 备注
thead 定义表格的列头的行 一个表格中仅有一个
tbody 定义表格的主体 用来封装一组表行(tr元素)
tfoot 定义表格的各列汇总行 一个表格中仅有一个

样式布局

基本格式

在head标签中,通过style标签加入样式。

基本格式:可以含有多个属性,一个属性名也可以含有多个值,同时设置多样式。

1
2
3
4
5
6
7
<style>
标签名{
属性名1:属性值1;
属性名2:属性值2;
属性名:属性值1 属性值2 属性值3;
}
</style>

背景格式

background属性用来设置背景相关的样式。

  • 背景色
    [background-color]属性定义任何元素的背景色

    1
    2
    3
    body {
    background-color: #567895;
    }
  • 背景图
    该[background-image]属性允许在元素的背景中显示图像。使用url函数指定图片路径

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8">
    <title>背景图片</title>
    <style>
    body{
    /*添加背景图片*/
    background: url("../img/bg.png");
    }
    </style>
    </head>
    <body>

    </body>
    </html>

  • 背景重复

    [background-repeat]属性用于控制图像的平铺行为。可用值:

    • no-repeat -停止完全重复背景
    • repeat-x —水平重复
    • repeat-y —竖直重复
    • repeat—默认值;双向重复
    1
    2
    3
    4
    body {
    background-image: url(star.png);
    background-repeat: repeat-x;/*水平重复*/
    }


div布局

  • div简单布局:

    • broader:边界
    • solid:实线
    • blue:颜色
    1
    2
    3
    4
    5
    6
    7
    <style>
    div{ border: 1px solid blue;}
    </style>

    <div >left</div>
    <div >center</div>
    <div>right</div>

  • class值
    可以设置宽度,浮动,背景

    1
    2
    3
    4
    5
    6
    .class值{
    属性名:属性值;
    }

    <标签名 class="class值">
    提示: class是自定义的值
    • 属性

      • background:背景颜色

      • width:宽度 (npx 或者 n%)

      • height:长度

      • text-align:文本对齐方式

      • background-image: url(“../img/bg.png”):背景图

      • float:浮动

        指定一个元素应沿其容器的左侧或右侧放置,允许文本或者内联元素环绕它,该元素从网页的正常流动中移除,其他部分保持正常文档流顺序。

        1
        2
        3
        4
        5
        6
        7
        <!-- 加入浮动 -->
        float:none;不浮动
        float:left;左浮动
        float:right;右浮动

        <!-- 清除浮动 -->
        clear:both;清除两侧浮动,此元素不再收浮动元素布局影响。
  • div基本布局

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8">
    <title>样式演示</title>
    <style>
    /*给div标签添加边框*/
    div{
    border: 1px solid red;
    }

    /*左侧图片的div样式*/
    .left{
    width: 20%;
    float: left;
    height: 500px;
    }

    /*中间正文的div样式*/
    .center{
    width: 59%;
    float: left;
    height: 500px;
    }

    /*右侧广告图片的div样式*/
    .right{
    width: 20%;
    float: left;
    height: 500px;
    }

    /*底部超链接的div样式*/
    .footer{
    /*清除浮动效果*/
    clear: both;
    /*文本对齐方式*/
    text-align: center;
    /*背景颜色*/
    background: blue;
    }
    </style>
    </head>
    <body>
    <!--顶部登陆注册-->
    <div>top</div>

    <!--导航条-->
    <div>navibar</div>

    <!--左侧图片-->
    <div class="left">left</div>

    <!--中间正文-->
    <div class="center">center</div>

    <!--右侧广告图片-->
    <div class="right">right</div>

    <!--底部页脚超链接-->
    <div class="footer">footer</div>
    </body>
    </html>


语义化标签

为了更好的组织文档,HTML5规范中设计了几个语义元素,可以将特殊含义传达给浏览器。

标签 名称 作用 备注
header 标头元素 表示内容的介绍 块元素,文档中可以定义多个
nav 导航元素 表示导航链接 常见于网站的菜单,目录和索引等,可以嵌套在header中
article 文章元素 表示独立内容区域 标签定义的内容本身必须是有意义且必须独立于文档的其他部分
footer 页脚元素 表示页面的底部 块元素,文档中可以定义多个


HTML拓展

音频标签

<audio>:用于播放声音,比如音乐或其他音频流,是 HTML 5 的新标签。

常用属性:

属性名 取值 描述
src URL 音频资源的路径
autoplay autoplay 音频准备就绪后自动播放
controls controls 显示控件,比如播放按钮。
loop loop 表示循环播放
preload preload 音频在页面加载时进行预加载。
如果使用 “autoplay”,则忽略该属性。
HTML5媒体标签-音频audio

视频标签

<video> 标签用于播放视频,比如电影片段或其他视频流,是 HTML 5 的新标签。

常用属性:

属性名 取值 描述
src URL 要播放的视频的 URL。
width 设置视频播放器的宽度。
height 设置视频播放器的高度。
autoplay autoplay 视频在就绪后自动播放。
control controls 显示控件,比如播放按钮。
loop loop 如果出现该属性,则当媒介文件完成播放后再次开始播放。
preload preload 视频在页面加载时进行加载。
如果使用 “autoplay”,则忽略该属性。
mute muted 规定视频的音频输出应该被静音。
poste URL 视频下载时显示的图像,或者视频播放前显示的图像。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>HTML5媒体标签-视频video</title>
</head>
<body>

<video src="media/movie.ogg" controls>
你的浏览器不支持 video 标签
</video>

</body>
</html>


回到顶部

在html里面锚点的作用: 通过a标签跳转到指定的位置.

1
<a href="#aId">回到顶部</a>
回到顶部

详情概要

summary标签来描述概要信息, 利用details标签来描述详情信息. 默认情况下是折叠展示, 想看见详情必须点击

1
2
3
4
<details>
<summary>概要信息</summary>
详情信息
</details>
概要信息 详情信息

CSS

CSS入门

概述

CSS (层叠样式表——Cascading Style Sheets,缩写为 CSS),简单的说,它是用于设置和布局网页的计算机语言。会告知浏览器如何渲染页面元素。例如,调整内容的字体,颜色,大小等样式,设置边框的样式,调整模块的间距等。

层叠:是指样式表允许以多种方式规定样式信息。可以规定在单个元素中,可以在页面头元素中,也可以在另一个CSS文件中,规定的方式会有次序的差别。

样式:是指丰富的样式外观。拿边框距离来说,允许任何设置边框,允许设置边框与框内元素的距离,允许设置边框与边框的距离等等。


组成

CSS是一门基于规则的语言—你能定义用于你的网页中特定元素的一组样式规则。这里面提到了两个概念,一是特定元素,二是样式规则。对应CSS的语法,也就是选择器(selects声明(eclarations

  • 选择器:指定要添加样式的 HTML元素的方式。可以使用标签名,class值,id值等多种方式。
  • 声明:形式为**属性(property):值(value)**,用于设置特定元素的属性信息。
    • 属性:指示文体特征,例如font-sizewidthbackground-color
    • 值:每个指定的属性都有一个值,该值指示您如何更改这些样式。

格式:

1
2
3
4
5
选择器 {
属性名:属性值;
属性名:属性值;
属性名:属性值;
}


实现

页面标题

今天开始学CSS


CSS语法

注释方式

CSS中的注释以/*和开头*/

1
2
3
4
5
6
/* 设置h1的样式 */
h1 {
color: blue;
background-color: yellow;
border: 1px solid black;
}

引入方式

内联样式

内联样式是CSS声明在元素的style属性中,仅影响一个元素:

  • 格式:

    1
    <标签 style="属性名:属性值; 属性名:属性值;">内容</标签>
  • 例如:

    1
    2
    3
    <h1 style="color: blue;background-color: yellow;border: 1px solid black;">
    Hello World!
    </h1>
  • 效果:

    Hello World!

  • 特点:格式简单,但是样式作用无法复用到多个元素上,不利于维护

内部样式表

内部样式表是将CSS样式放在style标签中,通常style标签编写在HTML 的head标签内部。

  • 格式:

    1
    2
    3
    4
    5
    6
    7
    8
    <head>
    <style>
    选择器 {
    属性名: 属性值;
    属性名: 属性值;
    }
    </style>
    </head>
  • 例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <head>
    <style>
    h1 {
    color: blue;
    background-color: yellow;
    border: 1px solid black;
    }
    </style>
    </head>
  • 特点:内部样式只能作用在当前页面上,如果是多个页面,就无法复用了

外部样式表

外部样式表是CSS附加到文档中的最常见和最有用的方法,因为您可以将CSS文件链接到多个页面,从而允许您使用相同的样式表设置所有页面的样式。

外部样式表是指将CSS编写在扩展名为.css 的单独文件中,并从HTML<link> 元素引用它,通常link标签`编写在HTML 的[head]标签内部。

  • 格式

    1
    <link rel="stylesheet" href="css文件">
    • rel:表示“关系 (relationship) ”,属性值指链接方式与包含它的文档之间的关系,引入css文件固定值为stylesheet。
    • href:属性需要引用某文件系统中的一个文件。
  • 举例

    • 创建styles.css文件

      1
      2
      3
      4
      5
      h1 {
      color: blue;
      background-color: yellow;
      border: 1px solid black;
      }
    • link标签引入文件

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      <!DOCTYPE html>
      <html>
      <head>
      <meta charset="utf-8">
      <link rel="stylesheet" href="styles.css">
      </head>
      <body>
      <h1>Hello World!</h1>
      </body>
      </html>

      效果同上

  • 为了CSS文件的管理,在项目中创建一个css文件夹,专门保存样式文件,并调整指定的路径以匹配

    1
    2
    <link rel="stylesheet" href="../css/styles.css">
    <!--..代表上一级 相对路径-->

优先级

规则层叠于一个样式表中,其中数字 4 拥有最高的优先权:

  1. 浏览器缺省设置
  2. 外部样式表
  3. 内部样式表(位于 标签内部)
  4. 内联样式(在 HTML 元素内部)

选择器

介绍选择器

为了样式化某些元素,我们会通过选择器来选中HTML文档中的这些元素,每个CSS规则都以一个选择器或一组选择器为开始,去告诉浏览器这些规则应该应用到哪些元素上。

选择器的分类:

分类 名称 符号 作用 示例
基本选择器 元素选择器 标签名 基于标签名匹配元素 div{ }
类选择器 . 基于class属性值匹配元素 .center{ }
ID选择器 # 基于id属性值匹配元素 #username{ }
通用选择器 * 匹配文档中的所有内容 *{ }
属性选择器 属性选择器 [] 基于某属性匹配元素 [type]{ }
伪类选择器 伪类选择器 : 用于向某些选择器添加特殊的效果 a:hover{ }
组合选择器 分组选择器 , 使用 , 号结合两个选择器,匹配两个选择器的元素 span,p{}
后代选择器 空格 使用空格符号结合两个选择器,基于
第一个选择器,匹配第二个选择器的所有后代元素
.top li{ }

基本选择器

  • 页面元素:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <body>
    <div>div1</div>

    <div class="cls">div2</div>
    <div class="cls">div3</div>

    <div id="d1">div4</div>
    <div id="d2">div5</div>
    </body>
  • 元素选择器

    1
    2
    3
    4
    /*选择所有div标签,字体为蓝色*/
    div{
    color: red;
    }
  • 类选择器

    1
    2
    3
    4
    /*选择class为cls的,字体为蓝色*/
    .cls{
    color: blue;
    }
  • ID选择器

    1
    2
    3
    4
    5
    6
    7
    8
    /*id选择器*/
    #d1{
    color: green;/*id为d1的字体变成绿色*/
    }

    #d2{
    color: pink;/*id为d2的字体变成粉色*/
    }/
  • 通用选择器

    1
    2
    3
    4
    /*所有标签 */
    *{
    background-color: aqua;
    }

属性选择器

  • 页面:

    1
    2
    3
    4
    5
    <body>
    用户名:<input type="text"/> <br/>
    密码:<input type="password"/> <br>
    邮箱:<input type="email"/> <br>
    </body>
  • 选择器:

    1
    2
    3
    4
    5
    6
    7
    8
    /*输入框中输入的字符是红色*/
    [type] {
    color: red;
    }
    /*输入框中输入的字符是蓝色*/
    [type=password] {
    color: blue;
    }

伪类选择器

  • 页面元素

    1
    2
    3
    <body>
    <a href="https://www.baidu.com" target="_blank">百度一下</a>
    </body>
  • 伪类选择器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    /*未访问的状态*/
    a:link{
    color: black;
    }

    /*已访问的状态*/
    a:visited{
    color: blue;
    }

    /*鼠标悬浮的状态*/
    a:hover{
    color: red;
    }

    /*已选中的状态*/
    a:active{
    color: yellow;
    }
  • 注意:伪类顺序 link ,visited,hover,active,否则有可能失效。


组合选择器

  • 页面:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    <body>
    <span>span</span> <br/>
    <p>段落</p>

    <div class="top">
    <ol>
    <li>aa</li>
    <li>bb</li>
    </ol>
    </div>
    <div class="center">
    <ol>
    <li>cc</li>
    <li>dd</li>
    </ol>
    </div>
    </body>
  • 分组选择器

    1
    2
    3
    4
    /*span p两个标签下的字体为蓝色*/
    span,p{
    color: blue;
    }
  • 后代选择器

    1
    2
    3
    4
    /*class为top下的所有li标签字体颜色为红色*/
    .top li{
    color: red;
    }

优先级

选择器优先级

  • ID选择器 > 类选择器 > 标签选择器 > 通用选择器
  • 如果优先级相同,那么就满足就近原则

边框样式

单个边框

  • 单个边框
    border:边框
    border-top: 上边框
    border-left: 左边框
    border-bottom: 底边框
    border-right: 右边框

  • 无边框,当border值为none时,可以让边框不显示

    1
    2
    3
    4
    5
    div {
    width: 200px;
    height: 200px;
    border: none;
    }
  • 圆角

    通过使用[border-radius]属性设置盒子的圆角,虽然能分别设置四个角,但是通常我们使用一个值,来设置整体效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#d1{
/*设置所有边框*/
/*border: 5px solid black;*/

/*设置上边框*/
border-top: 5px solid black;
/*设置左边框*/
border-left: 5px double red;
/*设置右边框*/
border-right: 5px dotted blue;
/*设置下边框*/
border-bottom: 5px dashed pink;

width: 150px;
height: 150px;
}

#d2{
border: 5px solid red;
/*设置边框的弧度*/
border-radius: 25px;
width: 150px;
height: 150px;
}
1
2
3
4
5
<body>
<div id="d1"></div>
<br/>
<div id="d2"></div>
</body>

边框轮廓

轮廓outline:是绘制于元素周围的一条线,位于边框边缘的外围,可起到突出元素的作用

  • 属性值:double:双实线 dotted:圆点 dashed:虚线 none:无
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>样式演示</title>
<style>
input{
outline: dotted;
}
</style>
</head>
<body>
用户名:<input type="text"/> <br/>
</body>
</html>


盒子模型

模型介绍

盒子模型是通过设置元素框元素内容外部元素的边距,而进行布局的方式。

  • element : 元素。
  • padding : 内边距,也有资料将其翻译为填充。
  • border : 边框。
  • margin : 外边距,也有资料将其翻译为空白或空白边。
边距

内边距、边框和外边距都是可选的,默认值是零。在 CSS 中,width 和 height 指的是内容区域的宽度和高度。

  • 外边距
    单独设置边框的外边距,设置上、右、下、左方向:

    1
    2
    3
    4
    margin-top
    margin-right
    margin-bottom
    margin-left
    • margin:  auto /*浏览器自动计算外边距,具有居中效果。*/
      
      1
      2
      3
      4
      5
      6

      * 一个值

      ```css
      /* 所有 4 个外边距都是 10px */
      margin:10px;
    • 两个值

      1
      2
      margin:10px 5px;/* 上外边距和下外边距是 10px*/
      margin:10px auto;/*右外边距和左外边距是 5px */
    • 三个值

      1
      2
      /* 上外边距是 10px,右外边距和左外边距是 5px,下外边距是 15px*/
      margin:10px 5px 15px;
    • 四个值

      1
      2
      3
      /*上外边距是 10px,右外边距是 5px,下外边距是 15px,左外边距是 20px*/
      /*上右下外*/
      margin:10px 5px 15px 20px;
  • 内边距
    与外边距类似,单独设置边框的内边距,设置上、右、下、左方向:

    1
    2
    3
    4
    padding-top
    padding-right
    padding-bottom
    padding-left
布局
  • 基本布局

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    <style>
    div{
    border: 2px solid blue;
    }
    .big{
    width: 200px;
    height: 200px;
    }
    .small{
    width: 100px;
    height: 100px;
    margin: 30px;/* 外边距 */
    }
    </style>

    <div class="big">
    <div class="small">
    </div>
    </div

  • 增加内边距会增加元素框的总尺寸

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
     <style>
    div{
    border: 2px solid blue;
    }
    .big{
    width: 200px;
    height: 200px;
    padding: 30px;/*内边距 */
    }
    .small{
    width: 100px;
    height: 100px;
    }
    </style>


文本样式

基本属性

属性名 作用 属性取值
width 宽度
height 高度
color 颜色
font-family 字体样式 宋体、楷体
font-size 字体大小 px : 像素,文本高度像素绝对数值。
em : 1em等于当前元素的父元素设置的字体大小,是相对数值
text-decoration 下划线 underline : 下划线
overline : 上划线
line-through : 删除线
none : 不要线条
text-align 文本水平对齐 lef : 左对齐文本
right : 右对齐文本
center : 使文本居中
justify : 使文本散布,改变单词间的间距,使文本所有行具有相同宽度。
line-height 行高,行间距
vertical-align 文本垂直对齐 top:居上 bottom:居下 middle:居中 或者百分比
display 元素如何显示 可以设置块级和行内元素的切换,也可以设置元素隐藏
inline:内联元素(无换行、无长宽)
block:块级元素(有换行)
inline-block:内联元素(有长宽)
none:隐藏元素
1
2
3
4
5
6
7
8
9
10
11
12
13
div{
color: /*red*/ #ff0000;
font-family: /*宋体*/ 微软雅黑;
font-size: 25px;/
text-decoration: none;
text-align: center;
line-height: 60px;
}

span{
/*文字垂直对齐 top:居上 bottom:居下 middle:居中 百分比*/
vertical-align: 50%; /*居中对齐*/
}
1
2
3
4
5
6
7
8
9
<div>
我是文字
</div>
<div>
我是文字
</div>

<img src="../img/wx.png" width="38px" height="38px"/>
<span>微信</span>


文本显示

  • 元素显示

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    /*   把列表项显示为内联元素,无长宽*/
    li {
    display:inline;
    }
    /* 把span元素作为块元素,有换行*/
    span {
    display:block;
    }
    /* 行内块元素,结合的行内和块级的优点,既可以行内显示,又可以设置长宽,*/
    li {
    display:inline-block;
    }
    /*所有div在一行显示*/
    div{
    display: inline-block;
    width: 100px;
    }
  • 元素隐藏

    当设置为none时,可以隐藏元素。


CSS案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/*背景图片*/
body{
background: url("../img/bg.png");
}

/*中间表单样式*/
.center{
background: white; /*背景色*/
width: 40%; /*宽度*/
margin: auto; /*水平居中外边距*/
margin-top: 100px; /*上外边距*/
border-radius: 15px; /*边框弧度*/
text-align: center; /*文本水平居中*/
}

/*表头样式*/
thead th{
font-size: 30px; /*字体大小*/
color: orangered; /*字体颜色*/
}

/*表体提示信息样式*/
tbody label{
font-size: 20px; /*字体大小*/
}

/*表体输入框样式*/
tbody input{
border: 1px solid gray; /*边框*/
border-radius: 5px; /*边框弧度*/
width: 90%; /*输入框的宽度*/
height: 40px; /*输入框的高度*/
outline: none; /*取消轮廓的样式*/
}

/*表底确定按钮样式*/
tfoot button{
border: 1px solid crimson; /*边框*/
border-radius: 5px; /*边框弧度*/
width: 95%; /*宽度*/
height: 40px; /*高度*/
background: crimson; /*背景色*/
color: white; /*文字的颜色*/
font-size: 20px; /*字体大小*/
}

/*表行高度*/
tr{
line-height: 60px; /*行高*/
}

/*底部页脚样式*/
.footer{
width: 35%; /*宽度*/
margin: auto; /*水平居中外边距*/
font-size: 15px; /*字体大小*/
color: gray; /*字体颜色*/
}

/*超链接样式*/
a{
text-decoration: none; /*去除超链接的下划线*/
color: blue; /*超链接颜色*/
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登录页面</title>
<link rel="stylesheet" href="../css/login.css"/>
</head>
<body>
<!--顶部公司图标-->
<div>
<img src="../img/logo.png"/>
</div>

<!--中间表单-->
<div class="center">
<form action="#" method="get" autocomplete="off">
<table width="100%">
<thead>
<tr>
<th colspan="2">&nbsp;&nbsp;&nbsp;<hr/></th>
</tr>
</thead>

<tbody>
<tr>
<td>
<label for="username">账号</label>
</td>
<td>
<input type="text" id="username" name="username" placeholder=" 请输入账号" required/>
</td>
</tr>
<tr>
<td>
<label for="password">密码</label>
</td>
<td>
<input type="password" id="password" name="password" placeholder=" 请输入密码" required/>
</td>
</tr>
</tbody>

<tfoot>
<tr>
<td colspan="2">
<button type="submit">&nbsp;</button>
</td>
</tr>
</tfoot>
</table>
</form>
</div>

<!--底部页脚-->
<div class="footer">
<br/><br/>
登录/注册即表示您同意&nbsp;&nbsp;
<a href="#" target="_blank">用户协议</a>&nbsp;&nbsp;
&nbsp;&nbsp;
<a href="#" target="_blank">隐私条款</a>&nbsp;&nbsp;&nbsp;&nbsp;
<a href="#" target="_blank">忘记密码?</a>
</div>
</body>
</html>


HTTP

相关概念

HTTP:Hyper Text Transfer Protocol,意为超文本传输协议,是建立在 TCP/IP 协议基础上,指的是服务器和客户端之间交互必须遵循的一问一答的规则,形容这个规则:问答机制、握手机制

HTTP 协议是一个无状态的面向连接的协议,指的是协议对于事务处理没有记忆能力,服务器不知道客户端是什么状态。所以打开一个服务器上的网页和上一次打开这个服务器上的网页之间没有任何联系

注意:无状态并不是代表 HTTP 就是 UDP,面向连接也不是代表 HTTP 就是TCP

HTTP 作用:用于定义 WEB 浏览器与 WEB 服务器之间交换数据的过程和数据本身的内容

浏览器和服务器交互过程:浏览器请求,服务请求响应

  • 请求(请求行、请求头、请求体)
  • 响应(响应行、响应头、响应体)

URL 和 URI

  • URL:统一资源定位符

  • URI:统一资源标志符

    • 格式:/request/servletDemo01
  • 区别:URL - HOST = URI,URI 是抽象的定义,URL 用地址定位,URI 用名称定位。只要能唯一标识资源的是 URI,在 URI 的基础上给出其资源的访问方式的是 URL

从浏览器地址栏输入 URL 到请求返回发生了什么?

  • 进行 URL 解析,进行编码

  • DNS 解析,顺序是先查 hosts 文件是否有记录,有的话就会把相对应映射的 IP 返回,然后去本地 DNS 缓存中寻找,然后依次向本地域名服务器、根域名服务器、顶级域名服务器、权限域名服务器发起查询请求,最终返回 IP 地址给本地域名服务器

    本地域名服务器将得到的 IP 地址返回给操作系统,同时将 IP 地址缓存起来;操作系统将 IP 地址返回给浏览器,同时自己也将 IP 地址缓存起来

  • 查找到 IP 之后,进行 TCP 协议的三次握手建立连接

  • 发出 HTTP 请求,取文件指令

  • 服务器处理请求,返回响应

  • 释放 TCP 连接

  • 浏览器解析渲染页面

推荐阅读:https://xiaolincoding.com/network/


版本区别

版本介绍:

  • HTTP/0.9 仅支持 GET 请求,不支持请求头
  • HTTP/1.0 默认短连接(一次请求建议一次 TCP 连接,请求完就断开),支持 GET、POST、 HEAD 请求
  • HTTP/1.1 默认长连接(一次 TCP 连接可以多次请求);支持 PUT、DELETE、PATCH 等六种请求;增加 HOST 头,支持虚拟主机;支持断点续传功能
  • HTTP/2.0 多路复用,降低开销(一次 TCP 连接可以处理多个请求);服务器主动推送(相关资源一个请求全部推送);解析基于二进制,解析错误少,更高效(HTTP/1.X 解析基于文本);报头压缩,降低开销
  • HTTP/3.0 QUIC (Quick UDP Internet Connections),快速 UDP 互联网连接,基于 UDP 协议

HTTP 1.0 和 HTTP 1.1 的主要区别:

  • 长短连接:

    在HTTP/1.0中,默认使用的是短连接,每次请求都要重新建立一次连接,比如获取 HTML 和 CSS 文件,需要两次请求。HTTP 基于 TCP/IP 协议的,每一次建立或者断开连接都需要三次握手四次挥手,开销会比较大

    HTTP 1.1起,默认使用长连接 ,默认开启 Connection: keep-alive,Keep-Alive 有一个保持时间,不会永久保持连接。持续连接有非流水线方式和流水线方式 ,流水线方式是客户端在收到 HTTP 的响应报文之前就能接着发送新的请求报文,非流水线方式是客户端在收到前一个响应后才能发送下一个请求

    HTTP 协议的长连接和短连接,实质上是 TCP 协议的长连接和短连接

  • 错误状态响应码:在 HTTP1.1 中新增了 24 个错误状态响应码,如 409(Conflict)表示请求的资源与资源的当前状态发生冲突,410(Gone)表示服务器上的某个资源被永久性的删除

  • 缓存处理:在 HTTP1.0 中主要使用 header 里的 If-Modified-Since,Expires 来做为缓存判断的标准,HTTP1.1 则引入了更多的缓存控制策略,例如 Entity tag,If-Unmodified-Since,If-Match,If-None-Match等

  • 带宽优化及网络连接的使用:HTTP1.0 存在一些浪费带宽的现象,例如客户端只需要某个对象的一部分,而服务器却将整个对象送过来了,并且不支持断点续传功能,HTTP1.1 则在请求头引入了 range 头域,允许只请求资源的某个部分,即返回码是 206(Partial Content),这样就方便了开发者自由的选择以便于充分利用带宽和连接

  • HOST 头处理:在 HTTP1.0 中认为每台服务器都绑定一个唯一的 IP 地址,因此请求消息中的 URL 并没有传递主机名。HTTP1.1 时代虚拟主机技术发展迅速,在一台物理服务器上可以存在多个虚拟主机,并且共享一个 IP 地址,故 HTTP1.1 增加了 HOST 信息

HTTP 1.1 和 HTTP 2.0 的主要区别:

  • 新的二进制格式:HTTP1.1 基于文本格式传输数据,HTTP2.0 采用二进制格式传输数据,解析更高效
  • 多路复用:在一个连接里,允许同时发送多个请求或响应,并且这些请求或响应能够并行的传输而不被阻塞,避免 HTTP1.1 出现的队头堵塞问题
  • 头部压缩,HTTP1.1 的 header 带有大量信息,而且每次都要重复发送;HTTP2.0 把 header 从数据中分离,并封装成头帧和数据帧,使用特定算法压缩头帧。并且 HTTP2.0 在客户端和服务器端记录了之前发送的键值对,对于相同的数据不会重复发送。比如请求 A 发送了所有的头信息字段,请求 B 则只需要发送差异数据,这样可以减少冗余数据,降低开销
  • 服务端推送:HTTP2.0 允许服务器向客户端推送资源,无需客户端发送请求到服务器获取

安全请求

HTTP 和 HTTPS 的区别:

  • 端口 :HTTP 默认使用端口 80,HTTPS 默认使用端口 443
  • 安全性:HTTP 协议运行在 TCP 之上,所有传输的内容都是明文,客户端和服务器端都无法验证对方的身份;HTTPS 是运行在 SSL/TLS 之上的 HTTP 协议,SSL/TLS 运行在 TCP 之上,所有传输的内容都经过加密,加密采用对称加密,但对称加密的密钥用服务器方的证书进行了非对称加密
  • 资源消耗:HTTP 安全性没有 HTTPS 高,但是 HTTPS 比 HTTP 耗费更多服务器资源

对称加密和非对称加密

  • 对称加密:加密和解密使用同一个秘钥,把密钥转发给需要发送数据的客户机,中途会被拦截(类似于把带锁的箱子和钥匙给别人,对方打开箱子放入数据,上锁后发送),私钥用来解密数据,典型的对称加密算法有 DES、AES 等

    • 优点:运算速度快
    • 缺点:无法安全的将密钥传输给通信方
  • 非对称加密:加密和解密使用不同的秘钥,一把作为公开的公钥,另一把作为私钥,公钥公开给任何人(类似于把锁和箱子给别人,对方打开箱子放入数据,上锁后发送),典型的非对称加密算法有 RSA、DSA 等

    • 公钥加密,私钥解密:为了保证内容传输的安全,因为被公钥加密的内容,其他人是无法解密的,只有持有私钥的人,才能解密出实际的内容
    • 私钥加密,公钥解密:为了保证消息不会被冒充,因为私钥是不可泄露的,如果公钥能正常解密出私钥加密的内容,就能证明这个消息是来源于持有私钥身份的人发送的
    • 可以更安全地将公开密钥传输给通信发送方,但是运算速度慢
  • 使用对称加密和非对称加密的方式传送数据

    • 使用非对称密钥加密方式,传输对称密钥加密方式所需要的 Secret Key,从而保证安全性
    • 获取到 Secret Key 后,再使用对称密钥加密方式进行通信,从而保证效率

    思想:锁上加锁

名词解释:

  • 哈希算法:通过哈希函数计算出内容的哈希值,传输到对端后会重新计算内容的哈希,进行哈希比对来校验内容的完整性

  • 数字签名:附加在报文上的特殊加密校验码,可以防止报文被篡改。一般是通过私钥对内容的哈希值进行加密,公钥正常解密并对比哈希值后,可以确保该内容就是对端发出的,防止出现中间人替换的问题

  • 数字证书:由权威机构给某网站颁发的一种认可凭证

HTTPS 工作流程:服务器端的公钥和私钥,用来进行非对称加密,客户端生成的随机密钥,用来进行对称加密

  1. 客户端向服务器发起 HTTPS 请求,连接到服务器的 443 端口,请求携带了浏览器支持的加密算法和哈希算法,协商加密算法
  2. 服务器端会向数字证书认证机构注册公开密钥,认证机构用 CA 私钥对公开密钥做数字签名后绑定在数字证书(又叫公钥证书,内容有公钥,网站地址,证书颁发机构,失效日期等)
  3. 服务器将数字证书发送给客户端,私钥由服务器持有
  4. 客户端收到服务器端的数字证书后通过 CA 公钥(事先置入浏览器或操作系统)对证书进行检查,验证其合法性。如果公钥合格,那么客户端会生成一个随机值,这个随机值就是用于进行对称加密的密钥,将该密钥称之为 client key(客户端密钥、会话密钥)。用服务器的公钥对客户端密钥进行非对称加密,这样客户端密钥就变成密文,HTTPS 中的第一次 HTTP 请求结束
  5. 客户端会发起 HTTPS 中的第二个 HTTP 请求,将加密之后的客户端密钥发送给服务器
  6. 服务器接收到客户端发来的密文之后,会用自己的私钥对其进行非对称解密,解密之后的明文就是客户端密钥,然后用客户端密钥对数据进行对称加密,这样数据就变成了密文
  7. 服务器将加密后的密文发送给客户端
  8. 客户端收到服务器发送来的密文,用客户端密钥对其进行对称解密,得到服务器发送的数据,这样 HTTPS 中的第二个 HTTP 请求结束,整个 HTTPS 传输完成

参考文章:https://www.cnblogs.com/linianhui/p/security-https-workflow.html

参考文章:https://www.jianshu.com/p/14cd2c9d2cd2


请求部分

请求行: 永远位于请求的第一行

请求头: 从第二行开始,到第一个空行结束

请求体: 从第一个空行后开始,到正文的结束(GET 没有)

  • 请求方式

    • POST

    • GET

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      【请求行】
      GET /myApp/success.html?username=zs&password=123456 HTTP/1.1

      【请求头】
      Accept: text/html, application/xhtml+xml, */*; X-HttpWatch-RID: 41723-10011
      Referer: http://localhost:8080/myApp/login.html
      Accept-Language: zh-Hans-CN,zh-Hans;q=0.5
      User-Agent: Mozilla/5.0 (MSIE 9.0; qdesk 2.4.1266.203; Windows NT 6.3; WOW64; Trident/7.0; rv:11.0) like Gecko
      Accept-Encoding: gzip, deflate
      Host: localhost:8080
      Connection: Keep-Alive
      Cookie: Idea-b77ddca6=4bc282fe-febf-4fd1-b6c9-72e9e0f381e8
    • GET 和 POST 比较

      作用:GET 用于获取资源,而 POST 用于传输实体主体

      参数:GET 和 POST 的请求都能使用额外的参数,但是 GET 的参数是以查询字符串出现在 URL 中,而 POST 的参数存储在实体主体中(GET 也有请求体,POST 也可以通过 URL 传输参数)。不能因为 POST 参数存储在实体主体中就认为它的安全性更高,因为照样可以通过一些抓包工具(Fiddler)查看

      安全:安全的 HTTP 方法不会改变服务器状态,也就是说它只是可读的。GET 方法是安全的,而 POST 不是,因为 POST 的目的是传送实体主体内容

      • 安全的方法除了 GET 之外还有:HEAD、OPTIONS
      • 不安全的方法除了 POST 之外还有 PUT、DELETE

      幂等性:同样的请求被执行一次与连续执行多次的效果是一样的,服务器的状态也是一样的,所有的安全方法也都是幂等的。在正确实现条件下,GET,HEAD,PUT 和 DELETE 等方法都是幂等的,POST 方法不是

      可缓存:如果要对响应进行缓存,需要满足以下条件

      • 请求报文的 HTTP 方法本身是可缓存的,包括 GET 和 HEAD,但是 PUT 和 DELETE 不可缓存,POST 在多数情况下不可缓存
      • 响应报文的状态码是可缓存的,包括:200、203、204、206、300、301、404、405、410、414 and 501
      • 响应报文的 Cache-Control 首部字段没有指定不进行缓存
    • PUT 和 POST 的区别

      PUT 请求:如果两个请求相同,后一个请求会把第一个请求覆盖掉(幂等),所以 PUT 用来修改资源

      POST 请求:后一个请求不会把第一个请求覆盖掉(非幂等),所以 POST 用来创建资源

      PATCH 方法 是新引入的,是对 PUT 方法的补充,用来对已知资源进行局部更新

  • 请求行详解

    1
    2
    GET  /myApp/success.html?username=zs&password=123456 HTTP/1.1	
    POST /myApp/success.html HTTP/1.1
    内容 说明
    GET/POST 请求的方式。
    /myApp/success.html 请求的资源。
    HTTP/1.1 使用的协议,及协议的版本。
  • 请求头详解

    从第 2 行到空行处,都叫请求头,以键值对的形式存在,但存在一个 key 对应多个值的请求头

    内容 说明
    Accept 告知服务器,客户浏览器支持的 MIME 类型
    User-Agent 浏览器相关信息
    Accept-Charset 告诉服务器,客户浏览器支持哪种字符集
    Accept-Encoding 告知服务器,客户浏览器支持的压缩编码格式,常用 gzip 压缩
    Accept-Language 告知服务器,客户浏览器支持的语言,zh_CN 或 en_US 等
    Host 初始 URL 中的主机和端口
    Referer 告知服务器,当前请求的来源。只有当前请求有来源,才有这个消息头。
    作用:1 投放广告 2 防盗链
    Content-Type 告知服务器,请求正文的 MIME 类型,文件传输的类型,
    application/x-www-form-urlencoded
    Content-Length 告知服务器,请求正文的长度。
    Connection 表示是否需要持久连接,一般是 Keep -Alive(HTTP 1.1 默认进行持久连接 )
    If-Modified-Since 告知服务器,客户浏览器缓存文件的最后修改时间
    Cookie 会话管理相关(非常的重要)
  • 请求体详解

    • 只有 POST 请求方式,才有请求的正文,GET 方式的正文是在地址栏中的

    • 表单的输入域有 name 属性的才会被提交,不分 GET 和 POST 的请求方式

    • 表单的 enctype 属性取值决定了请求正文的体现形式

      enctype取值 请求正文体现形式 示例
      application/x-www-form-urlencoded key=value&key=value username=test&password=1234
      multipart/form-data 此时变成了多部分表单数据。多部分是靠分隔符分隔的。 —————————–7df23a16c0210
      Content-Disposition: form-data; name=”username”
      test
      —————————–7df23a16c0210
      Content-Disposition: form-data; name=”password”
      1234
      ——————————-7df23a16c0210

响应部分

响应部分图:

  • 响应行

    HTTP/1.1:使用协议的版本

    200:响应状态码

    OK:状态码描述

    • 响应状态码:

      状态码 说明
      200 一切都 OK,与服务器连接成功,发送请求成功
      302/307 请求重定向(客户端行为,两次请求,地址栏发生改变)
      304 请求资源未改变,使用缓存
      400 客户端错误,请求错误,最常见的就是请求参数有问题
      403 客户端错误,但 forbidden 权限不够,拒绝处理
      404 客户端错误,请求资源未找到
      500 服务器错误,服务器运行内部错误

    转移:

    • 301 redirect:301 代表永久性转移 (Permanently Moved)
    • 302 redirect:302 代表暂时性转移 (Temporarily Moved )
  • 响应头:以 key:vaue 存在,可能多个 value 情况

    消息头 说明
    Location 请求重定向的地址,常与 302,307 配合使用。
    Server 服务器相关信息
    Content-Type 告知客户浏览器,响应正文的MIME类型
    Content-Length 告知客户浏览器,响应正文的长度
    Content-Encoding 告知客户浏览器,响应正文使用的压缩编码格式,常用的 gzip 压缩
    Content-Language 告知客户浏览器,响应正文的语言,zh_CN 或 en_US 等
    Content-Disposition 告知客户浏览器,以下载的方式打开响应正文
    Refresh 客户端的刷新频率,单位是秒
    Last-Modified 服务器资源的最后修改时间
    Set-Cookie 服务器端发送的 Cookie,会话管理相关
    Expires:-1 服务器资源到客户浏览器后的缓存时间
    Catch-Control: no-catch 不要缓存,//针对http协议1.1版本
    Pragma:no-catch 不要缓存,//针对http协议1.0版本
  • 响应体:页面展示内容, 类似网页的源码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <html>
    <head>
    <link rel="stylesheet" href="css.css" type="text/css">
    <script type="text/javascript" src="demo.js"></script>
    </head>
    <body>
    <img src="1.jpg" />
    </body>
    </html>

Servlet

JavaEE

JavaEE规范

JavaEE 规范是 J2EE 规范的新名称,早期被称为 J2EE 规范,其全称是 Java 2 Platform Enterprise Edition,它是由 SUN 公司领导、各厂家共同制定并得到广泛认可的工业标准(JCP组织成员)。之所以改名为JavaEE,目的还是让大家清楚 J2EE 只是 Java 企业应用。在 2004 年底中国软件技术大会 Ioc 微容器(也就是 Jdon 框架的实现原理)演讲中指出:我们需要一个跨 J2SE/WEB/EJB 的微容器,保护我们的业务核心组件,以延续它的生命力,而不是依赖 J2SE/J2EE 版本。此次 J2EE 改名为 Java EE,实际也反映出业界这种共同心声

JavaEE 规范是很多 Java 开发技术的总称。这些技术规范都是沿用自 J2EE 的。一共包括了 13 个技术规范,例如:jsp/servletjndijaxpjdbcjnijaxbjmfjtajpaEJB等。

其中,JCP 组织的全称是 Java Community Process,是一个开放的国际组织,主要由 Java 开发者以及被授权者组成,职能是发展和更新。成立于 1998 年。官网是:JCP

JavaEE 的版本是延续了 J2EE 的版本,但是没有继续采用其命名规则。J2EE 的版本从 1.0 开始到 1.4 结束,而 JavaEE 版本是从 JavaEE 5 版本开始,目前最新的的版本是 JavaEE 8

详情请参考:JavaEE8 规范概览


Web 概述

Web,在计算机领域指网络。像我们接触的 WWW,它是由 3 个单词组成的,即:World Wide Web ,中文含义是万维网。而我们前面学的 HTML 的参考文档《W3School 全套教程》中的 W3C 就是万维网联盟,他们的出现都是为了让我们在网络的世界中获取资源,这些资源的存放之处,我们称之为网站。我们通过输入网站的地址(网址),就可以访问网站中提供的资源。在网上我们能访问到的内容全是资源(不区分局域网还是广域网),只不过不同类型的资源展示的效果不一样

资源分为静态资源和动态资源

  • 静态资源指的是,网站中提供给人们展示的资源是一成不变的,也就是说不同人或者在不同时间,看到的内容都是一样的。例如:我们看到的新闻,网站的使用手册,网站功能说明文档等等。而作为开发者,我们编写的 htmlcssjs 图片,多媒体等等都可以称为静态资源

  • 动态资源它指的是,网站中提供给人们展示的资源是由程序产生的,在不同的时间或者用不同的人员由于身份的不同,所看到的内容是不一样的。例如:我们在CSDN上下载资料,只有登录成功后,且积分足够时才能下载。否则就不能下载,这就是访客身份和会员身份的区别。作为开发人员,我们编写的 JSPservletphpASP 等都是动态资源。

关于广域网和局域网的划分

  • 广域网指的就是万维网,也就是我们说的互联网。
  • 局域网是指的是在一定范围之内可以访问的网络,出了这个范围,就不能再使用的网络。

系统结构

基础结构划分:C/S结构,B/S结构两类。

技术选型划分:Model1模型,Model2模型,MVC模型和三层架构+MVC模型。

部署方式划分:一体化架构,垂直拆分架构,分布式架构,流动计算架构,微服务架构。

  • C/S结构:客户端—服务器的方式。其中C代表Client,S代表服务器。C/S结构的系统设计图如下:

  • B/S结构是浏览器—服务器的方式。B代表Browser,S代表服务器。B/S结构的系统设计图如下:

  • 两种结构的区别及优劣

    • 区别:

      • 第一:硬件环境不同,C/S通常是建立在专用的网络或小范围的网络环境上(即局域网),且必须要安装客户端。而B/S是建立在广域网上的,适应范围强,通常有操作系统和浏览器就行。
      • 第二:C/S结构比B/S结构更安全,因为用户群相对固定,对信息的保护更强。
      • 第三:B/S结构维护升级比较简单,而C/S结构维护升级相对困难。
    • 优劣

      • C/S:能充分发挥客户端PC的处理能力,很多工作可以在客户端处理后再提交给服务器。对应的优点就是客户端响应速度快。
      • B/S:总体拥有成本低、维护方便、 分布性强、开发简单,可以不用安装任何专门的软件就能实现在任何地方进行操作,客户端零维护,系统的扩展非常容易,只要有一台能上网的电脑就能使用。
  • 我们的课程中涉及的系统结构都是是基于B/S结构


Tomcat

服务器

服务器的概念非常的广泛,它可以指代一台特殊的计算机(相比普通计算机运行更快、负载更高、价格更贵),也可以指代用于部署网站的应用。我们这里说的服务器,其实是web服务器,或者应用服务器。它本质就是一个软件,一个应用。作用就是发布我们的应用(工程),让用户可以通过浏览器访问我们的应用。

常见的应用服务器,请看下表:

服务器名称 说明
weblogic 实现了 JavaEE 规范,重量级服务器,又称为 JavaEE 容器
websphereAS 实现了 JavaEE 规范,重量级服务器。
JBOSSAS 实现了 JavaEE 规范,重量级服务器,免费
Tomcat 实现了 jsp/servlet 规范,是一个轻量级服务器,开源免费

基本介绍

Windows安装

下载地址:http://tomcat.apache.org/

目录结构详解:


Linux安装

解压apache-tomcat-8.5.32.tar.gz。

防火墙设置

  • 方式1:service iptables stop 关闭防火墙(不建议); 用到哪一个端口号就放行哪一个(80,8080,3306…)

  • 方式2:放行8080 端口

    • 修改配置文件cd /etc/sysconfig–>vi iptables
      -A INPUT -m state --state NEW -m tcp -p tcp --dport 8080 -j ACCEPT
    • 重启加载防火墙或者重启防火墙
      service iptables reload 或者service iptables restart

启动停止

Tomcat服务器的启动文件在二进制文件目录bin中:startup.bat,startup.sh

Tomcat服务器的停止文件也在二进制文件目录bin中:shutdown.bat,shutdown.sh (推荐直接关闭控制台)

其中.bat文件是针对windows系统的运行程序,.sh文件是针对linux系统的运行程序。


常见问题

  • 启动一闪而过

    没有配置环境变量,配置上 JAVA_HOME 环境变量。

  • Tomcat 启动后控制台输出乱码

    打开 /conf/logging.properties,设置 gbk java.util.logging.ConsoleHandler.encoding = gbk

  • Address already in use : JVM_Bind:端口被占用,找到占用该端口的应用

    • 进程不重要:使用cmd命令:netstat -a -o 查看 pid 在任务管理器中结束占用端口的进程

    • 进程很重要:修改自己的端口号。修改的是 Tomcat 目录下\conf\server.xml中的配置。


IDEA集成

Run -> Edit Configurations -> Templates -> Tomcat Server -> Local


发布应用

虚拟目录

server.xml<Host> 元素中加一个 <Context path="" docBase=""/> 元素

  • path:访问资源URI,URI名称可以随便起,但是必须在前面加上一个/
  • docBase:资源所在的磁盘物理地址

虚拟主机

<Engine>元素中添加一个<Host name="" appBase="" unparkWARs="" autoDeploy="" />,其中:

  • name:指定主机的名称
  • appBase:当前主机的应用发布目录
  • unparkWARs:启动时是否自动解压war包
  • autoDeploy:是否自动发布
1
2
3
<Host name="www.itcast.cn" appBase="D:\itcastapps" unpackWARs="true" autoDeploy="true"/>

<Host name="www.itheima.com" appBase="D:\itheimaapps" unpackWARs="true" autoDeploy="true"/>

IDEA部署

  • 新建工程

  • 发布工程

  • Run


IDEA发布

把资源移动到 Tomcat 工程下 web 目录中,两种访问方式


执行原理

整体架构

Tomcat 核心组件架构图如下所示:

组件介绍:

  • GlobalNamingResources:实现 JNDI,指定一些资源的配置信息
  • Server:Tomcat 是一个 Servlet 容器,一个 Tomcat 对应一个 Server,一个 Server 可以包含多个 Service
  • Service:核心服务是 Catalina,用来对请求进行处理,一个 Service 包含多个 Connector 和一个 Container
  • Connector:连接器,负责处理客户端请求,解析不同协议及 I/O 方式
  • Executor:线程池
  • Container:容易包含 Engine,Host,Context,Wrapper 等组件
  • Engine:服务交给引擎处理请求,Container 容器中顶层的容器对象,一个 Engine 可以包含多个 Host 主机
  • Host:Engine 容器的子容器,一个 Host 对应一个网络域名,一个 Host 包含多个 Context
  • Context:Host 容器的子容器,表示一个 Web 应用
  • Wrapper:Tomcat 中的最小容器单元,表示 Web 应用中的 Servlet

核心类库:

  • Coyote:Tomcat 连接器的名称,封装了底层的网络通信,为 Catalina 容器提供了统一的接口,使容器与具体的协议以及 I/O 解耦
  • EndPoint:Coyote 通信端点,即通信监听的接口,是 Socket 接收和发送处理器,是对传输层的抽象,用来实现 TCP/IP 协议
  • Processor : Coyote 协议处理接口,用来实现 HTTP 协议,Processor 接收来自 EndPoint 的 Socket,读取字节流解析成 Tomcat 的 Request 和 Response 对象,并通过 Adapter 将其提交到容器处理,Processor 是对应用层协议的抽象
  • CoyoteAdapter:适配器,连接器调用 CoyoteAdapter 的 sevice 方法,传入的是 TomcatRequest 对象,CoyoteAdapter 负责将TomcatRequest 转成 ServletRequest,再调用容器的 service 方法

参考文章:https://www.jianshu.com/p/7c9401b85704

参考文章:https://www.yuque.com/yinhuidong/yu877c/ktq82e


启动过程

Tomcat 的启动入口是 Bootstrap#main 函数,首先通过调用 bootstrap.init() 初始化相关组件:

  • initClassLoaders():初始化三个类加载器,commonLoader 的父类加载器是启动类加载器
  • Thread.currentThread().setContextClassLoader(catalinaLoader):自定义类加载器加载 Catalina 类,打破双亲委派
  • Object startupInstance = startupClass.getConstructor().newInstance():反射创建 Catalina 对象
  • method.invoke(startupInstance, paramValues):反射调用方法,设置父类加载器是 sharedLoader
  • catalinaDaemon = startupInstance:引用 Catalina 对象

daemon.load(args) 方法反射调用 Catalina 对象的 load 方法,对服务器的组件进行初始化,并绑定了 ServerSocket 的端口:

  • parseServerXml(true):解析 XML 配置文件

  • getServer().init():服务器执行初始化,采用责任链的执行方式

    • LifecycleBase.init():生命周期接口的初始化方法,开始链式调用

    • StandardServer.initInternal():Server 的初始化,遍历所有的 Service 进行初始化

    • StandardService.initInternal():Service 的初始化,对 Engine、Executor、listener、Connector 进行初始化

    • StandardEngine.initInternal():Engine 的初始化

      • getRealm():创建一个 Realm 对象
      • ContainerBase.initInternal():容器的初始化,设置处理容器内组件的启动和停止事件的线程池
    • Connector.initInternal():Connector 的初始化

      1
      2
      3
      public Connector() {
      this("HTTP/1.1"); //默认无参构造方法,会创建出 Http11NioProtocol 的协议处理器
      }
      • adapter = new CoyoteAdapter(this):实例化 CoyoteAdapter 对象

      • protocolHandler.setAdapter(adapter):设置到 ProtocolHandler 协议处理器中

      • ProtocolHandler.init():协议处理器的初始化,底层调用 AbstractProtocol#init 方法

        endpoint.init():端口的初始化,底层调用 AbstractEndpoint#init 方法

        NioEndpoint.bind():绑定方法

        • initServerSocket()初始化 ServerSocket,以 NIO 的方式监听端口
          • serverSock = ServerSocketChannel.open()NIO 的方式打开通道
          • serverSock.bind(addr, getAcceptCount()):通道绑定连接端口
          • serverSock.configureBlocking(true):切换为阻塞模式(没懂,为什么阻塞)
        • initialiseSsl():初始化 SSL 连接
        • selectorPool.open(getName()):打开选择器,类似 NIO 的多路复用器

初始化完所有的组件,调用 daemon.start() 进行组件的启动,底层反射调用 Catalina 对象的 start 方法:

  • getServer().start():启动组件,也是责任链的模式

    • LifecycleBase.start():生命周期接口的初始化方法,开始链式调用

    • StandardServer.startInternal():Server 服务的启动

      • globalNamingResources.start():启动 JNDI 服务
      • for (Service service : services):遍历所有的 Service 进行启动
    • StandardService.startInternal():Service 的启动,对所有 Executor、listener、Connector 进行启

    • StandardEngine.startInternal():启动引擎,部署项目

      • ContainerBase.startInternal():容器的启动
        • 启动集群、Realm 组件,并且创建子容器,提交给线程池
        • ((Lifecycle) pipeline).start():遍历所有的管道进行启动
          • Valve current = first:获取第一个阀门
          • ((Lifecycle) current).start():启动阀门,底层 ValveBase#startInternal 中设置启动的状态
          • current = current.getNext():获取下一个阀门
    • Connector.startInternal():Connector 的初始化

      • protocolHandler.start():协议处理器的启动

        endpoint.start():端点启动

        NioEndpoint.startInternal():启动 NIO 的端点

        • createExecutor():创建 Worker 线程组,10 个线程,用来进行任务处理
        • initializeConnectionLatch():用来进行连接限流,最大 8*1024 条连接
        • poller = new Poller()创建 Poller 对象,开启了一个多路复用器 Selector
        • Thread pollerThread = new Thread(poller, getName() + "-ClientPoller"):创建并启动 Poller 线程,Poller 实现了 Runnable 接口,是一个任务对象,线程 start 后进入 Poller#run 方法
        • pollerThread.setDaemon(true):设置为守护线程
        • startAcceptorThread():启动接收者线程
          • acceptor = new Acceptor<>(this)创建 Acceptor 对象
          • Thread t = new Thread(acceptor, threadName):创建并启动 Acceptor 接受者线程

处理过程

  1. Acceptor 监听客户端套接字,每 50ms 调用一次 **serverSocket.accept**,获取 Socket 后把封装成 NioSocketWrapper(是 SocketWrapperBase 的子类),并设置为非阻塞模式,把 NioSocketWrapper 封装成 PollerEvent 放入同步队列中
  2. Poller 循环判断同步队列中是否有就绪的事件,如果有则通过 selector.selectedKeys() 获取就绪事件,获取 SocketChannel 中携带的 attachment(NioSocketWrapper),在 processKey 方法中根据事件类型进行 processSocket,将 Wrapper 对象封装成 SocketProcessor 对象,该对象是一个任务对象,提交到 Worker 线程池进行执行
  3. SocketProcessorBase.run() 加锁调用 SocketProcessor#doRun,保证线程安全,从协议处理器 ProtocolHandler 中获取 AbstractProtocol,然后创建 Http11Processor 对象处理请求
  4. Http11Processor#service 中调用 CoyoteAdapter#service ,把生成的 Tomcat 下的 Request 和 Response 对象通过方法 postParseRequest 匹配到对应的 Servlet 的请求响应,将请求传递到对应的 Engine 容器中调用 Pipeline,管道中包含若干个 Valve,执行完所有的 Valve 最后执行 StandardEngineValve,继续调用 Host 容器的 Pipeline,执行 Host 的 Valve,再传递给 Context 的 Pipeline,最后传递到 Wrapper 容器
  5. StandardWrapperValve#invoke 中创建了 Servlet 对象并执行初始化,并为当前请求准备一个 FilterChain 过滤器链执行 doFilter 方法,ApplicationFilterChain#doFilter 是一个责任链的驱动方法,通过调用 internalDoFilter 来获取过滤器链的下一个过滤器执行 doFilter,执行完所有的过滤器后执行 servlet.service 的方法
  6. 最后调用 HttpServlet#service(),根据请求的方法来调用 doGet、doPost 等,执行到自定义的业务方法

Servlet

Socket

Socket 是使用 TCP/IP 或者 UDP 协议在服务器与客户端之间进行传输的技术,是网络编程的基础

  • Servlet 是使用 HTTP 协议在服务器与客户端之间通信的技术,是 Socket 的一种应用
  • HTTP 协议:是在 TCP/IP 协议之上进一步封装的一层协议,关注数据传输的格式是否规范,底层的数据传输还是运用了 Socket 和 TCP/IP

Tomcat 和 Servlet 的关系:Servlet 的运行环境叫做 Web 容器或 Servlet 服务器,Tomcat 是 Web 应用服务器,是一个 Servlet/JSP 容器。Tomcat 作为 Servlet 容器,负责处理客户请求,把请求传送给 Servlet,并将 Servlet 的响应传送回给客户。而 Servlet 是一种运行在支持 Java 语言的服务器上的组件,Servlet 用来扩展 Java Web 服务器功能,提供非常安全的、可移植的、易于使用的 CGI 替代品


基本介绍

Servlet类

Servlet是SUN公司提供的一套规范,名称就叫Servlet规范,它也是JavaEE规范之一。通过API来使用Servlet。

  1. Servlet是一个运行在web服务端的java小程序,用于接收和响应客户端的请求。一个服务器包含多个Servlet

  2. 通过实现Servlet接口,继承GenericServlet或者HttpServlet,实现Servlet功能

  3. 每次请求都会执行service方法,在service方法中还有参数ServletRequest和ServletResponse

  4. 支持配置相关功能


执行流程

创建 Web 工程 → 编写普通类继承 Servlet 相关类 → 重写方法

Servlet执行过程分析:

通过浏览器发送请求,请求首先到达Tomcat服务器,由服务器解析请求URL,然后在部署的应用列表中找到应用。然后找到web.xml配置文件,在web.xml中找到FirstServlet的配置(/),找到后执行service方法,最后由FirstServlet响应客户浏览器。整个过程如下图所示:


实现方式

实现 Servlet 功能时,可以选择以下三种方式:

  • 第一种:实现 Servlet 接口,接口中的方法必须全部实现。
    使用此种方式,表示接口中的所有方法在需求方面都有重写的必要。此种方式支持最大程度的自定义。

  • 第二种:继承 GenericServlet,service 方法必须重写,其他方可根据需求,选择性重写。
    使用此种方式,表示只在接收和响应客户端请求这方面有重写的需求,而其他方法可根据实际需求选择性重写,使我们的开发Servlet变得简单。但是,此种方式是和 HTTP 协议无关的。

  • 第三种:继承 HttpServlet,它是 javax.servlet.http 包下的一个抽象类,是 GenericServlet 的子类。选择继承 HttpServlet 时,需要重写 doGet 和 doPost 方法,来接收 get 方式和 post 方式的请求,不要覆盖 service 方法。使用此种方式,表示我们的请求和响应需要和 HTTP 协议相关,我们是通过 HTTP 协议来访问。每次请求和响应都符合 HTTP 协议的规范。请求的方式就是 HTTP 协议所支持的方式(GET POST PUT DELETE TRACE OPTIONS HEAD )。


相关问题

异步处理

Servlet 3.0 中的异步处理指的是允许Servlet重新发起一条新线程去调用 耗时业务方法,这样就可以避免等待


生命周期

servlet从创建到销毁的过程:

  • 出生:(初始化)请求第一次到达 Servlet 时,创建对象,并且初始化成功。Only one time

  • 活着:(服务)服务器提供服务的整个过程中,该对象一直存在,每次只是执行 service 方法

  • 死亡:(销毁)当服务停止时,或者服务器宕机时,对象删除,

serrvlet生命周期方法:
init(ServletConfig config)service(ServletRequest req, ServletResponse res)destroy()

默认情况下, 有了第一次请求, 会调用 init() 方法进行初始化【调用一次】,任何一次请求,都会调用 service() 方法处理这个请求,服务器正常关闭或者项目从服务器移除, 调用 destory() 方法进行销毁【调用一次】

扩展:servlet 是单例多线程的,尽量不要在 servlet 里面使用全局(成员)变量,可能会导致线程不安全

  • 单例:Servlet 对象只会创建一次,销毁一次,Servlet 对象只有一个实例。
  • 多线程:服务器会针对每次请求, 开启一个线程调用 service() 方法处理这个请求

线程安全

Servlet运用了单例模式,整个应用中只有一个实例对象,所以需要分析这个唯一的实例中的类成员是否线程安全

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class ServletDemo extends HttpServlet{
//1.定义用户名成员变量
//private String username = null;
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String username = null;
//synchronized (this) {
//2.获取用户名
username = req.getParameter("username");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//3.获取输出流对象
PrintWriter pw = resp.getWriter();
//4.响应给客户端浏览器
pw.print("Welcome:" + username);
//5.关流
pw.close();
//}
}

@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
doGet(req,resp);
}
}

启动两个浏览器,输入不同的参数(http://localhost:8080/ServletDemo/username=aaa 或者bbb),访问之后发现输出的结果都是一样,所以出现线程安全问题。

在Servlet中定义了类成员之后,多个浏览器都会共享类成员的数据,其中任何一个线程修改了数据,都会影响其他线程。因此,我们可以认为Servlet它不是线程安全的。因为Servlet是单例,单例对象的类成员只会随类实例化时初始化一次,之后的操作都是改变,而不会重新初始化。

解决办法:如果类成员是共用的,只在初始化时赋值,其余时间都是获取。或者加锁synchronized


映射方式

Servlet支持三种映射方式,三种映射方式的优先级为:第一种>第二种>第三种。

  1. 具体名称方式
    这种方式,只有和映射配置一模一样时,Servlet才会接收和响应来自客户端的请求。
    访问URL:http://localhost:8080/servlet/servletDemo

    1
    2
    3
    4
    5
    6
    7
    8
    <servlet>
    <servlet-name>servletDemo</servlet-name>
    <servlet-class>com.itheima.servlet.ServletDemo</servlet-class>
    </servlet>
    <servlet-mapping>
    <servlet-name>servletDemo</servlet-name>
    <url-pattern>/servletDemo</url-pattern>
    </servlet-mapping>
  2. /开头+通配符的方式
    这种方式,只要符合目录结构即可,不用考虑结尾是什么
    访问URL:http://localhost:8080/servlet/ + 任何字符

    1
    2
    3
    4
    5
    6
    7
    8
    <servlet>
    <servlet-name>servletDemo</servlet-name>
    <servlet-class>com.itheima.servlet.ServletDemo</servlet-class>
    </servlet>
    <servlet-mapping>
    <servlet-name>servletDemo</servlet-name>
    <url-pattern>/servlet/*</url-pattern>
    </servlet-mapping>
  3. 通配符+固定格式结尾
    这种方式,只要符合固定结尾格式即可,其前面的访问URI无须关心(注意协议,主机和端口必须正确)
    访问URL:http://localhost:8080/任何字符任何目录 + .do (http://localhost:8080/seazean/i.do)

    1
    2
    3
    4
    5
    6
    7
    8
    <servlet>
    <servlet-name>servletDemo05</servlet-name>
    <servlet-class>com.itheima.servlet.ServletDemo05</servlet-class>
    </servlet>
    <servlet-mapping>
    <servlet-name>servletDemo05</servlet-name>
    <url-pattern>*.do</url-pattern>
    </servlet-mapping>

多路径映射

一个Servlet的多种路径配置的支持。给一个Servlet配置多个访问映射,从而根据不同请求的URL实现不同的功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/*多路映射*/
public class ServletDemo06 extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
int money = 1000;
//获取访问的资源路径
String name = req.getRequestURI();
name = name.substring(name.lastIndexOf("/"));

if("/vip".equals(name)) {
//如果访问资源路径是/vip 商品价格为9折
System.out.println("商品原价为:" + money + "。优惠后是:" + (money*0.9));
} else if("/svip".equals(name)) {
//如果访问资源路径是/svip 商品价格为5折
System.out.println("商品原价为:" + money + "。优惠后是:" + (money*0.5));
} else {
//如果访问资源路径是其他 商品价格原样显示
System.out.println("商品价格为:" + money);
}
}

@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
doGet(req,resp);
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!--演示Servlet多路径映射-->
<servlet>
<servlet-name>vip</servlet-name>
<servlet-class>com.itheima.servlet.ServletDemo06</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>vip</servlet-name>
<url-pattern>/vip</url-pattern>
</servlet-mapping>
<servlet>
<servlet-name>svip</servlet-name>
<servlet-class>com.itheima.servlet.ServletDemo06</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>svip</servlet-name>
<url-pattern>/svip</url-pattern>
</servlet-mapping>
<servlet>
<servlet-name>other</servlet-name>
<servlet-class>com.itheima.servlet.ServletDemo06</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>other</servlet-name>
<url-pattern>/other</url-pattern>
</servlet-mapping>

这样就可以根据不同的网页显示不同的数据。


启动时创建

  • 第一种:应用加载时创建Servlet,它的优势是在服务器启动时,就把需要的对象都创建完成了,从而在使用的时候减少了创建对象的时间,提高了首次执行的效率。它的弊端是在应用加载时就创建了Servlet对象,因此,导致内存中充斥着大量用不上的Servlet对象,造成了内存的浪费。
  • 第二种:请求第一次访问是创建Servlet,它的优势就是减少了对服务器内存的浪费,因为一直没有被访问过的Servlet对象都没有创建,因此也提高了服务器的启动时间。而它的弊端就是要在应用加载时就做的初始化操作,它都没法完成,从而要考虑其他技术实现。

在web.xml中是支持对Servlet的创建时机进行配置的,配置的方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
<!--配置ServletDemo3-->
<servlet>
<servlet-name>servletDemo</servlet-name>
<servlet-class>com.itheima.web.servlet.ServletDemo</servlet-class>
<!--配置Servlet的创建顺序,当配置此标签时,Servlet就会改为应用加载时创建
配置项的取值只能是正整数(包括0),数值越小,表明创建的优先级越高-->
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>servletDemo</servlet-name>
<url-pattern>/servletDemo</url-pattern>
</servlet-mapping>

默认Servlet

默认 Servlet 是由服务器提供的一个 Servlet,它配置在 Tomcat 的 conf 目录下的 web.xml 中。

它的映射路径是<url-pattern>/<url-pattern>,我们在发送请求时,首先会在我们应用中的 web.xml 中查找映射配置。但是当找不到对应的 Servlet 路径时,就去找默认的 Servlet,由默认 Servlet 处理。


ServletConfig

ServletConfig 是 Servlet 的配置参数对象。在 Servlet 规范中,允许为每个 Servlet 都提供一些初始化配置,每个 Servlet 都有自己的ServletConfig,作用是在 Servlet 初始化期间,把一些配置信息传递给 Servlet

生命周期:在初始化阶段读取了 web.xml 中为 Servlet 准备的初始化配置,并把配置信息传递给 Servlet,所以生命周期与 Servlet 相同。如果 Servlet 配置了 <load-on-startup>1</load-on-startup>,ServletConfig 也会在应用加载时创建。

获取 ServletConfig:在 init 方法中为 ServletConfig 赋值

常用API:

  • String getInitParameter(String name):根据初始化参数的名称获取参数的值,根据,获取
  • Enumeration<String> getInitParameterNames() : 获取所有初始化参数名称的枚举(遍历方式看例子)
  • ServletContext getServletContext() : 获取ServletContext对象
  • String getServletName() : 获取Servlet名称

代码实现:

  • web.xml 配置:
    初始化参数使用 <servlet> 标签中的 <init-param> 标签来配置,并且每个 Servlet 都支持有多个初始化参数,并且初始化参数都是以键值对的形式存在的

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    <!--配置ServletDemo8-->
    <servlet>
    <servlet-name>servletDemo8</servlet-name>
    <servlet-class>com.itheima.web.servlet.ServletDemo8</servlet-class>
    <!--配置初始化参数-->
    <init-param>
    <!--用于获取初始化参数的key-->
    <param-name>encoding</param-name>
    <!--初始化参数的值-->
    <param-value>UTF-8</param-value>
    </init-param>
    <!--每个初始化参数都需要用到init-param标签-->
    <init-param>
    <param-name>servletInfo</param-name>
    <param-value>This is Demo8</param-value>
    </init-param>
    </servlet>
    <servlet-mapping>
    <servlet-name>servletDemo8</servlet-name>
    <url-pattern>/servletDemo8</url-pattern>
    </servlet-mapping>
  • 代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    //演示Servlet的初始化参数对象
    public class ServletDemo8 extends HttpServlet {
    //定义Servlet配置对象ServletConfig
    private ServletConfig servletConfig;

    //在初始化时为ServletConfig赋值
    @Override
    public void init(ServletConfig config) throws ServletException {
    this.servletConfig = config;
    }
    /**
    * doGet方法输出一句话
    */
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    //1.输出ServletConfig
    System.out.println(servletConfig);
    //2.获取Servlet的名称
    String servletName= servletConfig.getServletName();
    System.out.println(servletName);
    //3.获取字符集编码
    String encoding = servletConfig.getInitParameter("encoding");
    System.out.println(encoding);
    //4.获取所有初始化参数名称的枚举
    Enumeration<String> names = servletConfig.getInitParameterNames();
    //遍历names
    while(names.hasMoreElements()){
    //取出每个name
    String name = names.nextElement();
    //根据key获取value
    String value = servletConfig.getInitParameter(name);
    System.out.println("name:"+name+",value:"+value);
    }
    //5.获取ServletContext对象
    ServletContext servletContext = servletConfig.getServletContext();
    System.out.println(servletContext);
    }

    //调用doGet方法
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    doGet(req,resp);
    }
    }
  • 效果:


ServletContext

ServletContext 对象是应用上下文对象。服务器为每一个应用都创建了一个 ServletContext 对象,ServletContext 属于整个应用,不局限于某个 Servlet,可以实现让应用中所有 Servlet 间的数据共享。

上下文代表了程序当下所运行的环境,联系整个应用的生命周期与资源调用,是程序可以访问到的所有资源的总和,资源可以是一个变量,也可以是一个对象的引用

生命周期:

  • 出生:应用一加载,该对象就被创建出来。一个应用只有一个实例对象(Servlet 和 ServletContext 都是单例的)
  • 活着:只要应用一直提供服务,该对象就一直存在。
  • 死亡:应用被卸载(或者服务器停止),该对象消亡。

域对象:指的是对象有作用域,即有作用范围,可以实现数据共享,不同作用范围的域对象,共享数据的能力不一样。

Servlet 规范中,共有4个域对象,ServletContext 是其中一个,web 应用中最大的作用域,叫 application 域,可以实现整个应用间的数据共享功能。

数据共享:

获取ServletContext:

  • Java 项目继承 HttpServlet,HttpServlet 继承 GenericServlet,GenericServlet 中有一个方法可以直接使用

    1
    2
    3
    public ServletContext getServletContext() {
    return this.getServletConfig().getServletContext();
    }
  • ServletRequest 类方法:

    1
    ServletContext getServletContext()//获取ServletContext对象

常用API:

  • String getInitParameter(String name) : 根据名称获取全局配置的参数
  • String getContextPath : 获取当前应用访问的虚拟目录
  • String getRealPath(String path) : 根据虚拟目录获取应用部署的磁盘绝对路径
  • void setAttribute(String name, Object object) : 向应用域对象中存储数据
  • Object getAttribute(String name) : 根据名称获取域对象中的数据,没有则返回null
  • void removeAttribute(String name) : 根据名称移除应用域对象中的数据

代码实现:

  • web.xml配置:
    配置的方式,需要在<web-app>标签中使用<context-param>来配置初始化参数,它的配置是针对整个应用的配置,被称为应用的初始化参数配置。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <!--配置应用初始化参数-->
    <context-param>
    <!--用于获取初始化参数的key-->
    <param-name>servletContextInfo</param-name>
    <!--初始化参数的值-->
    <param-value>This is application scope</param-value>
    </context-param>
    <!--每个应用初始化参数都需要用到context-param标签-->
    <context-param>
    <param-name>globalEncoding</param-name>
    <param-value>UTF-8</param-value>
    </context-param>
  • 代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    public class ServletContextDemo extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    //获取ServletContext对象
    ServletContext context = getServletContext();

    //获取全局配置的globalEncoding
    String value = context.getInitParameter("globalEncoding");
    System.out.println(value);//UTF-8

    //获取应用的访问虚拟目录
    String contextPath = context.getContextPath();
    System.out.println(contextPath);//servlet

    //根据虚拟目录获取应用部署的磁盘绝对路径
    //获取b.txt文件的绝对路径 web目录下
    String b = context.getRealPath("/b.txt");
    System.out.println(b);

    //获取c.txt文件的绝对路径 /WEB-INF目录下
    String c = context.getRealPath("/WEB-INF/c.txt");
    System.out.println(c);

    //获取a.txt文件的绝对路径 //src目录下
    String a = context.getRealPath("/WEB-INF/classes/a.txt");
    System.out.println(a);

    //向域对象中存储数据
    context.setAttribute("username","zhangsan");

    //移除域对象中username的数据
    //context.removeAttribute("username");
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    doGet(req,resp);
    }
    }

    //E:\Database\Java\Project\JavaEE\out\artifacts\Servlet_war_exploded\b.txt
    //E:\Database\Java\Project\JavaEE\out\artifacts\Servlet_war_exploded\WEB-INF\c.txt
    //E:\Database\Java\Project\JavaEE\out\artifacts\Servlet_war_exploded\WEB-INF\classes\a.txt

注解开发

Servlet3.0 版本!不需要配置 web.xml

  • 注解案例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @WebServlet("/servletDemo1")
    public class ServletDemo1 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    doPost(req,resp);
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    System.out.println("Servlet Demo1 Annotation");
    }
    }
  • WebServlet注解(@since Servlet 3.0 (Section 8.1.1))

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface WebServlet {
    //指定Servlet的名称。相当于xml配置中<servlet>标签下的<servlet-name>
    String name() default "";

    //用于映射Servlet访问的url映射,相当于xml配置时的<url-pattern>
    String[] value() default {};

    //相当于xml配置时的<url-pattern>
    String[] urlPatterns() default {};

    //用于配置Servlet的启动时机,相当于xml配置的<load-on-startup>
    int loadOnStartup() default -1;

    //用于配置Servlet的初始化参数,相当于xml配置的<init-param>
    WebInitParam[] initParams() default {};

    //用于配置Servlet是否支持异步,相当于xml配置的<async-supported>
    boolean asyncSupported() default false;

    //用于指定Servlet的小图标
    String smallIcon() default "";

    //用于指定Servlet的大图标
    String largeIcon() default "";

    //用于指定Servlet的描述信息
    String description() default "";

    //用于指定Servlet的显示名称
    String displayName() default "";
    }
  • 手动创建容器:(了解)


Request

请求响应

Web服务器收到客户端的http请求,会针对每一次请求,分别创建一个用于代表请求的request对象、和代表响应的response对象。


请求对象

请求:客户机希望从服务器端索取一些资源,向服务器发出询问

请求对象:在 JavaEE 工程中,用于发送请求的对象,常用的对象是 ServletRequest 和 HttpServletRequest ,它们的区是是否与 HTTP 协议有关

Request 作用:

  • 操作请求三部分(行,头,体)
  • 请求转发
  • 作为域对象存数据


请求路径

方法 作用
String getLocalAddr() 获取本机(服务器)地址
String getLocalName() 获取本机(服务器)名称
int getLocalPort() 获取本机(服务器)端口
String getRemoteAddr() 获取访问者IP
String getRemoteHost 获取访问者主机
int getRemotePort() 获取访问者端口
String getMethod(); 获得请求方式
String getRequestURI() 获取统一资源标识符(/request/servletDemo01)
String getRequestURL() 获取统一资源定位符(http://localhost:8080/request/servletDemo01)
String getQueryString() 获取请求消息的数据
(GET方式 URL中带参字符串:username=aaa&password=123)
String getContextPath() 获取虚拟目录名称(/request)
String getServletPath 获取Servlet映射路径
或@WebServlet值: /servletDemo01)
String getRealPath(String path) 根据虚拟目录获取应用部署的磁盘绝对路径

URL = URI + HOST

URL = HOST + ContextPath + ServletPath


获取请求头

方法 作用
String getHeader(String name) 获得指定请求头的值。
如果没有该请求头返回null,有多个值返回第一个
Enumeration getHeaders(String name) 获取指定请求头的多个值
Enumeration getHeaderNames() 获取所有请求头名称的枚举
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@WebServlet("/servletDemo02")
public class ServletDemo02 extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
//1.根据请求头名称获取一个值
String connection = req.getHeader("connection");
System.out.println(connection);//keep-alive

//2.根据请求头名称获取多个值
Enumeration<String> values = req.getHeaders("accept-encoding");
while(values.hasMoreElements()) {
String value = values.nextElement();
System.out.println(value);//gzip, deflate, br
}
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
doGet(req,resp);
}
}

请求参数

请求参数

请求参数是正文部分标签内容,

标签属性action=”/request/servletDemo08”,服务器URI

法名 作用
String getParameter(String name) 获得指定参数名的值
如果没有该参数则返回null,如果有多个获得第一个
String[] getParameterValues(String name) 获得指定参数名所有的值。此方法为复选框提供的
Enumeration getParameterNames() 获得所有参数名
Map<String,String[]> getParameterMap() 获得所有的请求参数键值对(key=value)

封装参数

封装请求参数到类对象:

  • 直接封装:有参构造或者set方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    @WebServlet("/servletDemo04")
    public class ServletDemo04 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    //1.获取所有的数据
    String username = req.getParameter("username");
    String password = req.getParameter("password");
    String[] hobbies = req.getParameterValues("hobby");

    //2.封装学生对象
    Student stu = new Student(username,password,hobbies);

    //3.输出对象
    System.out.println(stu);

    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    doGet(req,resp);
    }
    }

    1
    2
    3
    4
    5
    6
    public class Student {
    private String username;
    private String password;
    private String[] hobby;

    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    <!--register.html-->
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8">
    <title>注册页面</title>
    </head>
    <body>
    <form action="/request/servletDemo05" method="get" autocomplete="off">
    姓名:<input type="text" name="username"> <br>
    密码:<input type="password" name="password"> <br>
    爱好:<input type="checkbox" name="hobby" value="study">学习
    <input type="checkbox" name="hobby" value="game">游戏 <br>
    <button type="submit">注册</button>
    </form>
    </body>
    </html>
  • 反射方式:

    表单<input>标签的name属性取值,必须和实体类中定义的属性名称一致

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    //1.获取请求正文的映射关系
    Map<String, String[]> map = req.getParameterMap();
    //2.封装学生对象
    Student stu = new Student();
    //2.1遍历集合
    for(String name : map.keySet()) {
    String[] value = map.get(name);
    try {
    //2.2获取Student对象的属性描述器
    //参数一:指定获取xxx属性的描述器
    //参数二:指定字节码文件
    PropertyDescriptor pd = new PropertyDescriptor(name,stu.getClass());
    //2.3获取对应的setXxx方法
    Method writeMethod = pd.getWriteMethod();
    //2.4执行方法
    if(value.length > 1) {
    writeMethod.invoke(stu,(Object)value);
    }else {
    writeMethod.invoke(stu,value);
    }
    } catch (Exception e) {
    e.printStackTrace();
    }
    }
    //3.输出对象
    System.out.println(stu);
    }
  • commons-beanutils封装

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    //1.获取所有的数据
    Map<String, String[]> map = req.getParameterMap();
    //2.封装学生对象
    Student stu = new Student();
    try {
    BeanUtils.populate(stu,map);
    } catch (Exception e) {
    e.printStackTrace();
    }
    //3.输出对象
    System.out.println(stu);

    }

流获取数据

ServletInputStream getInputStream() : 获取请求字节输入流对象
BufferedReader getReader() : 获取请求缓冲字符输入流对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@WebServlet("/servletDemo07")
public class ServletDemo07 extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
//字符流(必须是post方式)
/*BufferedReader br = req.getReader();
String line;
while((line = br.readLine()) != null) {
System.out.println(line);
}*/
//br.close();
//字节流
ServletInputStream is = req.getInputStream();
byte[] arr = new byte[1024];
int len;
while((len = is.read(arr)) != -1) {
System.out.println(new String(arr,0,len));
}
//is.close();
}

@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
doGet(req,resp);
}
}
1
2
<form action="/request/servletDemo07" method="get" autocomplete="off">
</form>

请求域

请求域

request 域:可以在一次请求范围内进行共享数据

方法 作用
void setAttribute(String name, Object value) 向请求域对象中存储数据
Object getAttribute(String name) 通过名称获取请求域对象的数据
void removeAttribute(String name) 通过名称移除请求域对象的数据

请求转发

请求转发:客户端的一次请求到达后,需要借助其他 Servlet 来实现功能,进行请求转发。特点:

  • 浏览器地址栏不变
  • 域对象中的数据不丢失
  • 负责转发的 Servlet 转发前后响应正文会丢失
  • 由转发目的地来响应客户端

HttpServletRequest 类方法:

  • RequestDispatcher getRequestDispatcher(String path) : 获取任务调度对象

RequestDispatcher 类方法:

  • void forward(ServletRequest request, ServletResponse response) : 实现转发,将请求从 Servlet 转发到服务器上的另一个资源(Servlet,JSP 文件或 HTML 文件)

过程:浏览器访问 http://localhost:8080/request/servletDemo09,/servletDemo10也会执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@WebServlet("/servletDemo09")
public class ServletDemo09 extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
//设置共享数据
req.setAttribute("encoding","gbk");
//获取请求调度对象
RequestDispatcher rd = req.getRequestDispatcher("/servletDemo10");
//实现转发功能
rd.forward(req,resp);
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
doGet(req,resp);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@WebServlet("/servletDemo10")
public class ServletDemo10 extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
//获取共享数据
Object encoding = req.getAttribute("encoding");
System.out.println(encoding);//gbk

System.out.println("servletDemo10执行了...");
}

@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
doGet(req,resp);
}
}


请求包含

请求包含:合并其他的 Servlet 中的功能一起响应给客户端。特点:

  • 浏览器地址栏不变
  • 域对象中的数据不丢失
  • 被包含的 Servlet 响应头会丢失

请求转发的注意事项:负责转发的 Servlet,转发前后的响应正文丢失,由转发目的地来响应浏览器

请求包含的注意事项:被包含者的响应消息头丢失,因为它被包含者包含起来了

HttpServletRequest 类方法:

  • RequestDispatcher getRequestDispatcher(String path) : 获取任务调度对象

RequestDispatcher 类方法:

  • void include(ServletRequest request, ServletResponse response) : 实现包含。包括响应中资源的内容(servlet,JSP页面,HTML文件)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@WebServlet("/servletDemo11")
public class ServletDemo11 extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
System.out.println("servletDemo11执行了...");//执行了
//获取请求调度对象
RequestDispatcher rd = req.getRequestDispatcher("/servletDemo12");
//实现包含功能
rd.include(req,resp);
}

@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
doGet(req,resp);
}
}
**********************************************************************************
@WebServlet("/servletDemo12")
public class ServletDemo12 extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
System.out.println("servletDemo12执行了...");//输出了
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
doGet(req,resp);
}
}

乱码问题

请求体

  • POST:void setCharacterEncoding(String env):设置请求体的编码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @WebServlet("/servletDemo08")
    public class ServletDemo08 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    //设置编码格式
    req.setCharacterEncoding("UTF-8");

    String username = req.getParameter("username");
    System.out.println(username);
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    doGet(req,resp);
    }
    }

  • GET:Tomcat8.5 版本及以后,Tomcat 服务器已经帮我们解决


Response

响应对象

响应,服务器把请求的处理结果告知客户端

响应对象:在 JavaEE 工程中,用于发送响应的对象

  • 协议无关的对象标准是:ServletResponse 接口
  • 协议相关的对象标准是:HttpServletResponse 接口

Response 的作用:

  • 操作响应的三部分(行, 头, 体)
  • 请求重定向


操作响应行

方法 说明
int getStatus() Gets the current status code of this response
void setStatus(int sc) Sets the status code for this response

状态码:(HTTP–>相应部分)

状态码 说明
1xx 消息
2xx 成功
3xx 重定向
4xx 客户端错误
5xx 服务器错误

操作响应体

字节流响应

响应体对应乱码问题

项目中常用的编码格式是UTF-8,而浏览器默认使用的编码是gbk。导致乱码!

解决方式:
一:修改浏览器的编码格式(不推荐,不能让用户做修改的动作)
二:通过输出流写出一个标签:<meta http-equiv=’content-type’content=’text/html;charset=UTF-8’>
三:指定响应头信息:response.setHeader(“Content-Type”,”text/html;charset=UTF-8”)
四:response.setContentType(“text/html;charset=UTF-8”)

常用API:
ServletOutputStream getOutputStream() : 获取响应字节输出流对象
void setContenType("text/html;charset=UTF-8") : 设置响应内容类型,解决中文乱码问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@WebServlet("/servletDemo01")
public class ServletDemo01 extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
//1.设置响应内容类型
resp.setContentType("text/html;charset=UTF-8");
//2.通过响应对象获取字节输出流对象
ServletOutputStream sos = resp.getOutputStream();
//3.定义消息
String str = "你好";
//4.通过字节流输出对象
sos.write(str.getBytes("UTF-8"));
}

@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
doGet(req,resp);
}
}


字符流响应

response得到的字符流和字节流互斥,只能选其一,response获取的流不用关闭,由服务器关闭即可。

常用API:
PrintWriter getWriter() : 获取响应字节输出流对象,可以发送标签
void setContenType("text/html;charset=UTF-8") : 设置响应内容类型,解决中文乱码问题

1
2
3
4
5
6
7
8
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String str = "你好";
//解决中文乱码
resp.setContentType("text/html;charset=UTF-8");
//获取字符流对象
PrintWriter pw = resp.getWriter();
pw.write(str);
}

响应图片

响应图片到浏览器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@WebServlet("/servletDemo03")
public class ServletDemo03 extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
//1.通过文件的相对路径来获取文件的绝对路径
String realPath = getServletContext().getRealPath("/img/hm.png");
//E:\Project\JavaEE\out\artifacts\Response_war_exploded\img\hm.png
System.out.println(realPath);
//2.创建字节输入流对象,关联图片路径
BufferedInputStream bis = new BufferedInputStream(new FileInputStream(realPath));

//3.通过响应对象获取字节输出流对象
ServletOutputStream sos = resp.getOutputStream();

//4.循环读写
byte[] arr = new byte[1024];
int len;
while((len = bis.read(arr)) != -1) {
sos.write(arr,0,len);
}
}

@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
doGet(req,resp);
}
}

操作响应头

常用方法

响应头: 是服务器指示浏览器去做什么

方法 说明
String getHeader(String name) 获取指定响应头的内容
Collection getHeaders(String name) 获取指定响应头的多个值
Collection getHeaderNames() 获取所有响应头名称的枚举
void setHeader(String name, String value) 设置响应头
void setDateHeader(String name, long date) 设置具有给定名称和日期值的响应消息头
void sendRedirect(String location) 设置重定向

setHeader常用响应头:

  • Expires:设置缓存时间
  • Refresh:定时跳转
  • Location:重定向地址
  • Content-Disposition: 告诉浏览器下载
  • Content-Type:设置响应内容的MIME类型(服务器告诉浏览器内容的类型)

控制缓存

缓存:对于不经常变化的数据,我们可以设置合理的缓存时间,防止浏览器频繁的请求服务器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@WebServlet("/servletDemo04")
public class ServletDemo04 extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String news = "设置缓存时间";
//设置缓存时间,缓存一小时
resp.setDateHeader("Expires",System.currentTimeMillis()+1*60*60*1000L);
//设置编码格式
resp.setContentType("text/html;charset=UTF-8");
//写出数据
resp.getWriter().write(news);
System.out.println("aaa");//只输出一次,不能刷新,必须从网址直接进入
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
doGet(req,resp);
}
}


定时刷新

定时刷新:过了指定时间后,页面进行自动跳转

格式:setHeader("Refresh", "3;URL=https://www.baidu.com"");
Refresh设置的时间单位是秒,如果刷新到其他地址,需要在时间后面拼接上地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@WebServlet("/servletDemo05")
public class ServletDemo05 extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String news = "您的用户名或密码错误,3秒后自动跳转到登录页面...";
//设置编码格式
resp.setContentType("text/html;charset=UTF-8");
//写出数据
resp.getWriter().write(news);

//设置响应消息头定时刷新
resp.setHeader("Refresh","3;URL=/response/login.html");
}

@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
doGet(req,resp);
}
}

下载文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@WebServlet("/servletDemo06")
public class ServletDemo06 extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
//1.创建字节输入流对象,关联读取的文件
String realPath = getServletContext().getRealPath("/img/hm.png");//绝对路径
BufferedInputStream bis = new BufferedInputStream(new FileInputStream(realPath));

//2.设置响应头支持的类型 应用支持的类型为字节流
/*
Content-Type 消息头名称 支持的类型
application/octet-stream 消息头参数 应用类型为字节流
*/
resp.setHeader("Content-Type","application/octet-stream");

//3.设置响应头以下载方式打开 以附件形式处理内容
/*
Content-Disposition 消息头名称 处理的形式
attachment;filename= 消息头参数 附件形式进行处理
*/
resp.setHeader("Content-Disposition","attachment;filename=" + System.currentTimeMillis() + ".png");

//4.获取字节输出流对象
ServletOutputStream sos = resp.getOutputStream();

//5.循环读写文件
byte[] arr = new byte[1024];
int len;
while((len = bis.read(arr)) != -1) {
sos.write(arr,0,len);
}

//6.释放资源
bis.close();
}

@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
doGet(req,resp);
}
}

重定向

实现重定向

请求重定向:客户端的一次请求到达后,需要借助其他 Servlet 来实现功能。特点:

  1. 重定向两次请求
  2. 重定向的地址栏路径改变
  3. 重定向的路径写绝对路径(带域名 /ip 地址,如果是同一个项目,可以省略域名 /ip 地址)
  4. 重定向的路径可以是项目内部的,也可以是项目以外的(百度)
  5. 重定向不能重定向到 WEB-INF 下的资源
  6. 把数据存到 request 域里面,重定向不可用

实现方式:

  • 方式一:

    1. 设置响应状态码:resp.setStatus(302)
    2. 设置重定向的路径(响应到哪里,通过响应头 location 来指定)
      • response.setHeader("Location","http://www.baidu.com");
      • response.setHeader("Location","/response/servletDemo08);
  • 方式二:

    • resp.sendRedirect("重定向的路径");
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@WebServlet("/servletDemo07")
public class ServletDemo07 extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
//设置请求域数据
req.setAttribute("username","zhangsan");

//设置重定向
resp.sendRedirect(req.getContextPath() + "/servletDemo07");
// resp.sendRedirect("https://www.baidu.com");
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
doGet(req,resp);
}
}

1
2
3
4
5
6
7
8
9
@WebServlet("/servletDemo08")
public class ServletDemo08 extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
System.out.println("servletDemo08执行了...");
Object username = req.getAttribute("username");
System.out.println(username);
}
}

重定向和转发

请求重定向跳转的特点:

  1. 重定向是由浏览器发起的,在这个过程中浏览器会发起两次请求
  2. 重定向可以跳转到任意服务器的资源,但是无法跳转到WEB-INF中的资源
  3. 重定向不能和请求域对象共享数据,数据会丢失
  4. 重定向浏览器的地址栏中的地址会变成跳转到的路径

请求转发跳转的特点:

  1. 请求转发是由服务器发起的,在这个过程中浏览器只会发起一次请求
  2. 请求转发只能跳转到本项目的资源,但是可以跳转到WEB-INF中的资源
  3. 请求转发可以和请求域对象共享数据,数据不会丢失
  4. 请求转发浏览器地址栏不变


路径问题

完整URL地址:

  1. 协议:http://
  2. 服务器主机地址:127.0.0.1 or localhost
  3. 服务器端口号:8080
  4. 项目的虚拟路径(部署路径):/response
  5. 具体的项目上资源路径 /login.html or Demo 的Servlet映射路径

相对路径:

不以”/“开头的路径写法,它是以目标路径相对当前文件的路径,其中”..”表示上一级目录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
</head>
<body>
<h1>hello world....</h1>
<!--
目标资源的url: http://localhost:8080/response/demo05
当前资源的url: http://localhost:8080/response/pages/demo.html
相对路径的优劣:
1. 优势: 无论部署的项目名怎么改变,我的路径都不需要改变
2. 劣势: 如果当前资源的位置发生改变,那么相对路径就必定要发生改变-->
<a href="../demo05">访问ServletDemo05</a>
</body>
</html>

绝对路径:

绝对路径就是以”/“开头的路径写法,项目部署的路径


会话技术

会话:浏览器和服务器之间的多次请求和响应

浏览器和服务器可能产生多次的请求和响应,从浏览器访问服务器开始,到访问服务器结束(关闭浏览器、到了过期时间),这期间产生的多次请求和响应加在一起称为浏览器和服务器之间的一次对话

作用:保存用户各自的数据(以浏览器为单位),在多次请求间实现数据共享

常用的会话管理技术

  • Cookie:客户端会话管理技术,用户浏览的信息以键值对(key=value)的形式保存在浏览器上。如果没有关闭浏览器,再次访问服务器,会把 cookie 带到服务端,服务端就可以做相应的处理

  • Session:服务端会话管理技术。当客户端第一次请求 session 对象时,服务器为每一个浏览器开辟一块内存空间,并将通过特殊算法算出一个 session 的 ID,用来标识该 session 对象。由于内存空间是每一个浏览器独享的,所有用户在访问的时候,可以把信息保存在 session 对象中,同时服务器会把 sessionId 写到 cookie 中,再次访问的时候,浏览器会把 cookie(sessionId) 带过来,找到对应的 session 对象即可

    tomcat 生成的 sessionID 叫做 jsessionID

两者区别:

  • Cookie 存储在客户端中,而 Session 存储在服务器上,相对来说 Session 安全性更高。如果要在 Cookie 中存储一些敏感信息,不要直接写入 Cookie,应该将 Cookie 信息加密然后使用到的时候再去服务器端解密

  • Cookie 一般用来保存用户信息,在 Cookie 中保存已经登录过得用户信息,下次访问网站的时候就不需要重新登录,因为用户登录的时候可以存放一个 Token 在 Cookie 中,下次登录的时候只需要根据 Token 值来查找用户即可(为了安全考虑,重新登录一般要将 Token 重写),所以登录一次网站后访问网站其他页面不需要重新登录

  • Session 通过服务端记录用户的状态,服务端给特定的用户创建特定的 Session 之后就可以标识这个用户并且跟踪这个用户

  • Cookie 只能存储 ASCII 码,而 Session 可以存储任何类型的数据

参考文章:https://blog.csdn.net/weixin_43625577/article/details/92393581


基本介绍

Cookie:客户端会话管理技术,把要共享的数据保存到了客户端(也就是浏览器端)。每次请求时,把会话信息带到服务器,从而实现多次请求的数据共享。

作用:保存客户浏览器访问网站的相关内容(需要客户端不禁用 Cookie),从而在每次访问同一个内容时,先从本地缓存获取,使资源共享,提高效率。


基本使用

常用API

  • Cookie属性:

    属性名称 属性作用 是否重要
    name cookie的名称 必要属性
    value cookie的值(不能是中文) 必要属性
    path cookie的路径 重要
    domain cookie的域名 重要
    maxAge cookie的生存时间 重要
    version cookie的版本号 不重要
    comment cookie的说明 不重要

    注意:Cookie 有大小,个数限制。每个网站最多只能存20个 Cookie,且大小不能超过 4kb。同时所有网站的 Cookie 总数不超过300个。

  • Cookie类API:

    • Cookie(String name, String value) : 构造方法创建 Cookie 对象

    • Cookie 属性对应的 set 和 get 方法,name 属性被 final 修饰,没有 set 方法

  • HttpServletResponse 类 API:

    • void addCookie(Cookie cookie):向客户端添加 Cookie,Adds cookie to the response
  • HttpServletRequest类API:

    • Cookie[] getCookies():获取所有的 Cookie 对象,client sent with this request

有效期

如果不设置过期时间,表示这个 Cookie 生命周期为浏览器会话期间,只要关闭浏览器窗口 Cookie 就消失,这种生命期为浏览会话期的 Cookie 被称为会话 Cookie,会话 Cookie 一般不保存在硬盘上而是保存在内存里。

如果设置过期时间,浏览器就会把 Cookie 保存到硬盘上,关闭后再次打开浏览器,这些 Cookie 依然有效直到超过设定的过期时间。存储在硬盘上的 Cookie 可以在不同的浏览器进程间共享,比如两个 IE 窗口,而对于保存在内存的 Cookie,不同的浏览器有不同的处理方式

设置 Cookie 存活时间 API:void setMaxAge(int expiry)

  • -1:默认,代表 Cookie 数据存到浏览器关闭(保存在浏览器文件中)
  • 0:代表删除 Cookie,如果要删除 Cookie 要确保路径一致
  • 正整数:以秒为单位保存数据有有效时间(把缓存数据保存到磁盘中)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@WebServlet("/servletDemo01")
public class ServletDemo01 extends HttpServlet{
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
//1.通过响应对象写出提示信息
resp.setContentType("text/html;charset=UTF-8");
PrintWriter pw = resp.getWriter();
pw.write("欢迎访问本网站,您的最后访问时间为:<br>");

//2.创建Cookie对象,用于记录最后访问时间
Cookie cookie = new Cookie("time",System.currentTimeMillis()+"");

//3.设置最大存活时间
cookie.setMaxAge(3600);
//cookie.setMaxAge(0); // 立即清除

//4.将cookie对象添加到客户端
resp.addCookie(cookie);

//5.获取cookie
Cookie[] cookies = req.getCookies();
for(Cookie c : cookies) {
if("time".equals(c.getName())) {
//6.获取cookie对象中的value,进行写出
String value = c.getValue();
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
pw.write(sdf.format(Long.parseLong(value)));
}
}
}

@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
doGet(req,resp);
}
}


有效路径

setPath(String url) : Cookie 设置有效路径

有效路径作用 :

  1. 保证不会携带别的网站/项目里面的 Cookie 到我们自己的项目
  2. 路径不一样,Cookie 的 key 可以相同
  3. 保证自己的项目可以合理的利用自己项目的 Cookie

判断路径是否携带 Cookie:请求资源 URI.startWith(cookie的path),返回 true 就带

访问URL URI部分 Cookie的Path 是否携带Cookie 能否取到Cookie
servletDemo02 /servlet/servletDemo02 /servlet/ 能取到
servletDemo03 /servlet/servletDemo03 /servlet/ 能取到
servletDemo04 /servlet/aaa/servletDemo04 /servlet/ 能取到
servletDemo05 /bbb/servletDemo04 /servlet/ 不带 不能取到

只有当访问资源的 url 包含此 cookie 的有效 path 的时候,才会携带这个 cookie

想要当前项目下的 Servlet 可以使用该 cookie,一般设置:cookie.setPath(request.getContextPath())


安全性

如果 Cookie 中设置了 HttpOnly 属性,通过 js 脚本将无法读取到 cookie 信息,这样能有效的防止 XSS 攻击,窃取 cookie 内容,这样就增加了安全性,即便是这样,也不要将重要信息存入cookie。

XSS 全称 Cross SiteScript,跨站脚本攻击,是Web程序中常见的漏洞,XSS 属于被动式且用于客户端的攻击方式,所以容易被忽略其危害性。其原理是攻击者向有 XSS 漏洞的网站中输入(传入)恶意的 HTML 代码,当其它用户浏览该网站时,这段HTML代码会自动执行,从而达到攻击的目的。如盗取用户 Cookie、破坏页面结构、重定向到其它网站等。


Session

基本介绍

Session:服务器端会话管理技术,本质也是采用客户端会话管理技术,不过在客户端保存的是一个特殊标识,共享的数据保存到了服务器的内存对象中。每次请求时,会将特殊标识带到服务器端,根据标识来找到对应的内存空间,从而实现数据共享。简单说它就是一个服务端会话对象,用于存储用户的会话数据

Session 域(会话域)对象是 Servlet 规范中四大域对象之一,并且它也是用于实现数据共享的

域对象 功能 创建 销毁 使用场景
ServletContext 应用域 服务器启动 服务器关闭 在整个应用之间实现数据共享
(记录网站访问次数,聊天室)
ServletRequest 请求域 请求到来 响应了这个请求 在当前请求或者请求转发之间实现数据共享
HttpSession 会话域 getSession() session过期,调用invalidate(),服务器关闭 在当前会话范围中实现数据共享,可以在多次请求中实现数据共享。
(验证码校验, 保存用户登录状态等)

基本使用

获取会话

HttpServletRequest类获取Session:

方法 说明
HttpSession getSession() 获取HttpSession对象
HttpSession getSession(boolean creat) 获取HttpSession对象,未获取到是否自动创建

常用API

方法 说明
void setAttribute(String name, Object value) 设置会话域中的数据
Object getAttribute(String name) 获取指定名称的会话域数据
Enumeration getAttributeNames() 获取所有会话域所有属性的名称
void removeAttribute(String name) 移除会话域中指定名称的数据
String getId() 获取唯一标识名称,Jsessionid的值
void invalidate() 立即失效session

实现会话

通过第一个Servlet设置共享的数据用户名,并在第二个Servlet获取到

项目执行完以后,去浏览器抓包,Request Headers 中的 Cookie JSESSIONID的值是一样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@WebServlet("/servletDemo01")
public class ServletDemo01 extends HttpServlet{
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
//1.获取请求的用户名
String username = req.getParameter("username");
//2.获取HttpSession的对象
HttpSession session = req.getSession();
System.out.println(session);
System.out.println(session.getId());
//3.将用户名信息添加到共享数据中
session.setAttribute("username",username);
}

@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
doGet(req,resp);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@WebServlet("/servletDemo02")
public class ServletDemo02 extends HttpServlet{
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
//1.获取HttpSession对象
HttpSession session = req.getSession();
//2.获取共享数据
Object username = session.getAttribute("username");
//3.将数据响应给浏览器
resp.getWriter().write(username+"");
}

@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
doGet(req,resp);
}
}

生命周期

Session 的创建:一个常见的错误是以为 Session 在有客户端访问时就被创建,事实是直到某 server 端程序(如 Servlet)调用 HttpServletRequest.getSession(true) 这样的语句时才会被创建

Session 在以下情况会被删除:

  • 程序调用 HttpSession.invalidate()
  • 距离上一次收到客户端发送的 session id 时间间隔超过了 session 的最大有效时间
  • 服务器进程被停止

注意事项:

  • 客户端只保存 sessionID 到 cookie 中,而不会保存 session
  • 关闭浏览器只会使存储在客户端浏览器内存中的 cookie 失效,不会使服务器端的 session 对象失效,同样也不会使已经保存到硬盘上的持久化cookie消失

打开两个浏览器窗口访问应用程序会使用的是不同的session,通常 session cookie 是不能跨窗口使用,当新开了一个浏览器窗口进入相同页面时,系统会赋予一个新的 session id,实现跨窗口信息共享:

  • 先把 session id 保存在 persistent cookie 中(通过设置session的最大有效时间)
  • 在新窗口中读出来,就可以得到上一个窗口的 session id,这样通过 session cookie 和 persistent cookie 的结合就可以实现跨窗口的会话跟踪

会话问题

禁用Cookie

浏览器禁用Cookie解决办法:

  • 方式一:通过提示信息告知用户

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    @WebServlet("/servletDemo03")
    public class ServletDemo03 extends HttpServlet{
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    //1.获取HttpSession对象
    HttpSession session = req.getSession(false);
    System.out.println(session);
    if(session == null) {
    resp.setContentType("text/html;charset=UTF-8");
    resp.getWriter().write("为了不影响正常的使用,请不要禁用浏览器的Cookie~");
    }
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    doGet(req,resp);
    }
    }
  • 方式二:访问时拼接 jsessionid 标识,通过 encodeURL() 方法重写地址

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    HttpSession session = req.getSession();
    //实现url重写 相当于在地址栏后面拼接了一个jsessionid
    resp.getWriter().write("<a href='"+ resp.encodeURL
    ("http://localhost:8080/session/servletDemo03") +
    "'>go servletDemo03</a>");

    }

钝化活化

Session 存放在服务器端的内存中,可以做持久化管理。

钝化:序列化,持久态。把长时间不用,但还不到过期时间的 HttpSession 进行序列化写到磁盘上。

活化:相反的状态

何时钝化:

  • 当访问量很大时,服务器会根据getLastAccessTime来进行排序,对长时间不用,但是还没到过期时间的HttpSession进行序列化(持久化)
  • 当服务器进行重启的时候,为了保持客户HttpSession中的数据,也要对HttpSession进行序列化(持久化)

注意:

  • HttpSession的持久化由服务器来负责管理,我们不用关心
  • 只有实现了序列化接口的类才能被序列化

JSP

JSP概述

JSP(Java Server Page):是一种动态网页技术标准。(页面技术)

JSP是基于Java语言的,它的本质就是Servlet,一个特殊的Servlet。

JSP部署在服务器上,可以处理客户端发送的请求,并根据请求内容动态的生成HTML、XML或其他格式文档的Web网页,然后响应给客户端。

类别 适用场景
HTML 开发静态资源,不能包含java代码,无法添加动态数据。
CSS 美化页面
JavaScript 给网页添加动态效果
Servlet 编写java代码,实现后台功能处理,但是很不方便,开发效率低。
JSP 包括了显示页面技术,同时具备Servlet输出动态资源的能力。但是不适合作为控制器来用。

执行原理

  • 新建JavaEE工程,编写index.jsp文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <%@ page contentType="text/html;charset=UTF-8" language="java" %>
    <html>
    <head>
    <title>JSP的入门</title>
    </head>
    <body>
    这是第一个JSP页面
    </body>
    </html>
  • 执行过程:

    客户端提交请求——Tomcat服务器解析请求地址——找到JSP页面——Tomcat将JSP页面翻译成Servlet的java文件——将翻译好的.java文件编译成.class文件——返回到客户浏览器上

  • 溯源,打开JSP翻译后的Java文件

    public final class index_jsp extends org.apache.jasper.runtime.HttpJspBasepublic abstract class HttpJspBase extends HttpServlet implements HttpJspPage,HttpJspBase是个抽象类继承HttpServlet,所以JSP本质上继承HttpServlet

    在文件中找到了输出页面的代码,本质都是用out.write()输出的JSP语句

  • 总结:
    JSP它是一个特殊的Servlet,主要是用于展示动态数据。它展示的方式是用流把数据输出出来,而我们在使用JSP时,涉及HTML的部分,都与HTML的用法一致,这部分称为jsp中的模板元素,决定了页面的外观。


JSP语法

  • JSP注释:

    注释类型 方法 作用
    JSP注释 <%–注释内容–%> 被jsp注释的部分不会被翻译成.java文件,不会在浏览器上显示
    HTML注释 在Jsp中可以使用html的注释,但是只能注释html元素
    被html注释部分会参与翻译,并且会在浏览器上显示
    Java注释 //; /* */
  • Java代码块

    1
    2
    <% 此处写java代码 %>
    <%--由tomcat负责翻译,翻译之后是service方法的成员变量--%>
  • JSP表达式

    1
    2
    <%=表达式%>
    <%--翻译成Service()方法里面的内容,相当于调用out.print()--%>
  • JSP声明

    1
    2
    <%! 声明的变量或方法 %>
    <%--翻译成Servlet类里面的内容--%>
  • 语法示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    <%@ page contentType="text/html;charset=UTF-8" language="java" %>
    <html>
    <head>
    <title>jsp语法</title>
    </head>
    <body>
    <%--1. 这是注释--%>

    <%--
    2.java代码块
    System.out.println("Hello JSP"); 普通输出语句,输出在控制台!!
    out.println("Hello JSP");out是JspWriter对象,输出在页面上
    --%>
    <%
    System.out.println("Hello JSP");
    out.println("Hello JSP<br>");
    String str = "hello<br>";
    out.println(str);
    %>

    <%--
    3.jsp表达式,相当于 out.println("Hello");
    --%>
    <%="Hello<br>"%>

    <%--
    4.jsp中的声明(变量或方法)
    如果加! 代表的是声明的是成员变量
    如果不加! 代表的是声明的是局部变量,页面显示abc
    --%>
    <%! String s = "abc";%>
    <% String s = "def";%>
    <%=s%>

    <%! public void getSum(){}%>
    </body>
    </html>
    1
    2
    3
    4
    5
    6
    控制台输出:Hello JSP
    页面输出:
    Hello JSP
    hello
    Hello
    def

JSP指令

  • page指令:

    1
    <%@ page  属性名=属性值 属性名=属性值... %>
    属性名 作用
    contentType 设置响应正文支持的MIME类型和编码格式:contentType=”text/html;charset=UTF-8”
    language 告知引擎,脚本使用的语言,默认为Java
    errorPage 当前页面出现异常后跳转的页面
    isErrorPage 是否抓住异常。值为true页面中就能使用exception对象,打印异常信息。默认值false
    import 导入哪些包(类)<%@ page import=”java.util.ArrayList” %>
    session 是否创建HttpSession对象,默认是true
    buffer 设定JspWriter用s输出jsp内容的缓存大小。默认8kb
    pageEncoding 翻译jsp时所用的编码格式,pageEncoding=”UTF-8”相当于用UTF-8读取JSP
    isELIgnored 是否忽略EL表达式,默认值是false

    Note:当使用全局错误页面,就无须配置errorPage实现跳转错误页面,而是由服务器负责跳转到错误页面

    • 配置全局错误页面:web.xml

      1
      2
      3
      4
      5
      6
      7
      8
      <error-page>    
      <exception-type>java.lang.Exception</exception-type>
      <location>/error.jsp</location>
      </error-page>
      <error-page>
      <error-code>404</error-code>
      <location>/404.html</location>
      </error-page>
  • include指令:包含其他页面

    1
    <%@include file="被包含的页面" %>

    属性:file,以/开头,就代表当前应用

  • taglib指令:引入外部标签库

    1
    <%taglib uri="标签库的地址" prefix="前缀名称"%>

    html标签和jsp标签不用引入


隐式对象

九大隐式对象

隐式对象:在jsp中可以不声明就直接使用的对象。它只存在于jsp中,因为java类中的变量必须要先声明再使用。
jsp中的隐式对象也并不是未声明,它是在翻译成.java文件时声明的,所以我们在jsp中可以直接使用。

隐式对象名称 类型 备注
request javax.servlet.http.HttpServletRequest
response javax.servlet.http.HttpServletResponse
session javax.servlet.http.HttpSession Page指令可以控制开关
application javax.servlet.ServletContext
page Java.lang.Object 当前jsp对应的servlet引用实例
config javax.servlet.ServletConfig
exception java.lang.Throwable page指令有开关
out javax.servlet.jsp.JspWriter 字符输出流,相当于printwriter
pageContext javax.servlet.jsp.PageContext 很重要,页面域

PageContext

  • PageContext对象特点:

    • PageContextd对象是JSP独有的对象,Servlet中没有
    • PageContextd对象是一个页面域(作用范围)对象,还可以操作其他三个域对象中的属性
    • PageContextd对象可以获取其他八个隐式对象
    • PageContextd对象是一个局部变量,它的生命周期随着JSP的创建而诞生,随着JSP的结束而消失。每个JSP页面都有一个独立的PageContext
  • PageContext方法如下,页面域操作的方法定义在了PageContext的父类JspContext中


四大域对象

域对象名称 范围 级别 备注
PageContext 页面范围 最小,只能在当前页面用 因范围太小,开发中用的很少
ServletRequest 请求范围 一次请求或当期请求转发用 当请求转发之后,再次转发时请求域丢失
HttpSession 会话范围 多次请求数据共享时使用 多次请求共享数据,但不同的客户端不能共享
ServletContext 应用范围 最大,整个应用都可以使用 尽量少用,如果对数据有修改需要做同步处理

MVC模型

M : model, 通常用于封装数据,封装的是数据模型
V : view,通常用于展示数据。动态展示用jsp页面,静态数据展示用html
C : controller,通常用于处理请求和响应,一般指的是Servlet


EL

EL概述

EL表达式:Expression Language,意为表达式语言。它是Servlet规范中的一部分,是JSP2.0规范加入的内容。

EL表达式作用:在JSP页面中获取数据,让JSP脱离java代码块和JSP表达式

EL表达式格式: ${表达式内容}

EL表达式特点:

  • 有明确的返回值
  • 把内容输出到页面
  • 只能在四大域对象中获取数据,不在四大域对象中的数据取不到。

EL用法

多种类型

EL表达式可以获取不同类型数据,前提是数据放入四大域对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
<%@ page import="bean.Student" %>
<%@ page import="java.util.ArrayList" %>
<%@ page import="java.util.HashMap" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>EL表达式获取不同类型数据</title>
</head>
<body>
<%--1.获取基本数据类型--%>
<% pageContext.setAttribute("num",10); %>
基本数据类型:${num} <br>

<%--2.获取自定义对象类型--%>
<%
Student stu = new Student("张三",23);
pageContext.setAttribute("stu",stu);
%>
自定义对象:${stu} <br>
<%--stu.name 实现原理 getName()--%>
学生姓名:${stu.name} <br>
学生年龄:${stu.age} <br>

<%--3.获取数组类型--%>
<%
String[] arr = {"hello","world"};
pageContext.setAttribute("arr",arr);
%>
数组:${arr} <br>
0索引元素:${arr[0]} <br>
1索引元素:${arr[1]} <br>

<%--4.获取List集合--%>
<%
ArrayList<String> list = new ArrayList<>();
list.add("aaa");
list.add("bbb");
pageContext.setAttribute("list",list);
%>
List集合:${list} <br>
0索引元素:${list[0]} <br>

<%--5.获取Map集合--%>
<%
HashMap<String,Student> map = new HashMap<>();
map.put("hm01",new Student("张三",23));
map.put("hm02",new Student("李四",24));
pageContext.setAttribute("map",map);
%>
Map集合:${map} <br>
第一个学生对象:${map.hm01} <br>
第一个学生对象的姓名:${map.hm01.name}
</body>
</html>

<--页面输出效果
基本数据类型:10
自定义对象:bean.Student@5f8da92c (地址)
学生姓名:张三
学生年龄:23
数组:[Ljava.lang.String;@4b3bd520
0索引元素:hello
1索引元素:world
List集合:[aaa, bbb]
0索引元素:aaa
Map集合:{hm01=bean.Student@4768d250, hm02=bean.Student@67f237d9}
第一个学生对象:bean.Student@4768d250
第一个学生对象的姓名:张三
-->

异常问题

EL表达式的注意事项:

  1. EL表达式没有空指针异常
  2. EL表达式没有数组下标越界
  3. EL表达式没有字符串拼接
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>EL表达式的注意事项</title>
</head>
<body>
第一个:没有空指针异常<br/>
<% String str = null;
request.setAttribute("testNull",str);
%>
str:${testNull}
<hr/>
第二个:没有数组下标越界<br/>
<% String[] strs = new String[]{"a","b","c"};
request.setAttribute("strs",strs);
%>
取第一个元素:${strs[0]}<br/>
取第六个元素:${strs[5]}<br/>
<hr/>
第三个:没有字符串拼接<br/>
<%--${strs[0]+strs[1]}--%>
拼接:${strs[0]}+${strs[1]} <%--注意拼接--%>
</body>
</html>

<--页面输出效果
第一个:没有空指针异常
str:
第二个:没有数组下标越界
取第一个元素:a
取第六个元素:
第三个:没有字符串拼接
拼接:a+b
-->

运算符

EL表达式中运算符:

  • 关系运算符:

  • 逻辑运算符:

    逻辑运算符 说明
    && 或 and 交集
    || 或 or 并集
    ! 或 not

  • 其他运算符

    运算符 作用
    empty 1. 判断对象是否为null
    2. 判断字符串是否为空字符串
    3. 判断容器元素是否为0
    条件 ? 表达式1 : 表达式2 三元运算符,条件?真:假
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>EL表达式运算符</title>
</head>
<body>
<%--empty--%>
<%
String str1 = null;
String str2 = "";
int[] arr = {};
%>
${empty str1} <br>
${empty str2} <br>
${empty arr} <br>

<%--三元运算符。获取性别的数据,在对应的按钮上进行勾选--%>
<% pageContext.setAttribute("gender","women"); %>
<input type="radio" name="gender" value="men" ${gender=="men"?"checked":""}>男
<input type="radio" name="gender" value="women" ${gender=="women"?"checked":""}>女
</body>
</html>


四大域数据

EL表达式只能从从四大域中获取数据,调用的就是findAttribute(name,value);方法,根据名称由小到大在域对象中查找,找到就返回,找不到就什么都不显示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>EL表达式使用细节</title>
</head>
<body>
<%--获取四大域对象中的数据--%>
<%
//pageContext.setAttribute("username","zhangsan");
request.setAttribute("username","zhangsan");
//session.setAttribute("username","zhangsan");
//application.setAttribute("username","zhangsan");
%>
${username} <br>

<%--获取JSP中其他八个隐式对象 获取虚拟目录名称--%>
<%= request.getContextPath()%>
${pageContext.request.contextPath}
</body>
</html>

EL隐式对象

EL表达式隐式对象

EL表达式也为我们提供隐式对象,可以让我们不声明直接来使用,需要注意的是,它和JSP的隐式对象不是同一种事物。

EL中的隐式对象 类型 对应JSP隐式对象 备注
PageContext Javax.serlvet.jsp.PageContext PageContext 完全一样
ApplicationScope Java.util.Map 没有 应用层范围
SessionScope Java.util.Map 没有 会话范围
RequestScope Java.util.Map 没有 请求范围
PageScope Java.util.Map 没有 页面层范围
Header Java.util.Map 没有 请求消息头key,值是value(一个)
HeaderValues Java.util.Map 没有 请求消息头key,值是数组(一个头多个值)
Param Java.util.Map 没有 请求参数key,值是value(一个)
ParamValues Java.util.Map 没有 请求参数key,值是数组(一个名称多个值)
InitParam Java.util.Map 没有 全局参数,key是参数名称,value是参数值
Cookie Java.util.Map 没有 Key是cookie的名称,value是cookie对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>EL表达式11个隐式对象</title>
</head>
<body>
<%--pageContext对象 可以获取其他三个域对象和JSP中八个隐式对象--%>
${pageContext.request.contextPath} <br>

<%--applicationScope sessionScope requestScope pageScope 操作四大域对象中的数据--%>
<% request.setAttribute("username","zhangsan"); %>
${username} <br>
${requestScope.username} <br>

<%--header headerValues 获取请求头数据--%>
${header["connection"]} <br>
${headerValues["connection"][0]} <br>

<%--param paramValues 获取请求参数数据--%>
${param.username} <br>
${paramValues.hobby[0]} <br>
${paramValues.hobby[1]} <br>

<%--initParam 获取全局配置参数--%>
${initParam["pname"]} <br>

<%--cookie 获取cookie信息--%>
${cookie} <br> <%--获取Map集合--%>
${cookie.JSESSIONID} <br> <%--获取map集合中第二个元素--%>
${cookie.JSESSIONID.name} <br> <%--获取cookie对象的名称--%>
${cookie.JSESSIONID.value} <%--获取cookie对象的值--%>
</body>
</html>
<--页面显示
/el
zhangsan
zhangsan
keep-alive
keep-alive

bbb
{JSESSIONID=javax.servlet.http.Cookie@435c8431, Idea-5a5d203e=javax.servlet.http.Cookie@46be0b58, Idea-be3279e7=javax.servlet.http.Cookie@4ef6e8e8}
javax.servlet.http.Cookie@435c8431
JSESSIONID
E481B2A845A448AD88A71FD43611FF02
-->

在web.xml配置全局参数

1
2
3
4
5
6
7
8
<?xml version="1.0" encoding="UTF-8"?>
<web-app ******>
<!--配置全局参数-->
<context-param>
<param-name>pname</param-name>
<param-value>bbb</param-value>
</context-param>
</web-app>

获取JSP隐式对象

通过获取页面域对象,获取其他JSP八个隐式对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>EL表达式使用细节</title>
</head>
<body>
<%--获取虚拟目录名称--%>
<%= request.getContextPath()%>
${pageContext.request.contextPath}
</body>
</html>
<--页面显示
/el /el
-->

JSTL

JSTL:Java Server Pages Standarded Tag Library,JSP中标准标签库。

作用:提供给开发人员一个标准的标签库,开发人员可以利用这些标签取代JSP页面上的Java代码,从而提高程序的可读性,降低程序的维护难度。

组成 作用 说明
Core 核心标签库 通用逻辑处理
Fmt 国际化有关 需要不同地域显示不同语言时使用
Functions EL函数 EL表达式可以使用的方法
SQL 操作数据库
XML 操作XML

使用:添加jar包,通过taglib导入,prefix属性表示程序调用标签使用的引用名

标签名称 功能分类 分类 作用
`<c:if test=”${A==B C==D}”>` 流程控制
<c:choose> ,<c:when>,<c:otherwise> 流程控制 核心标签库 用于多个条件判断
<c:foreache> 迭代操作 核心标签库 用于循环遍历
  • 流程控制

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    <%@ page contentType="text/html;charset=UTF-8" language="java" %>
    <%@taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c"%>
    <html>
    <head>
    <title>流程控制</title>
    </head>
    <body>
    <%--向域对象中添加成绩数据--%>
    ${pageContext.setAttribute("score","T")}

    <%--对成绩进行判断--%>
    <c:if test="${score eq 'A'}">
    优秀
    </c:if>

    <%--对成绩进行多条件判断--%>
    <c:choose>
    <c:when test="${score eq 'A'}">优秀</c:when>
    <c:when test="${score eq 'B'}">良好</c:when>
    <c:when test="${score eq 'C'}">及格</c:when>
    <c:when test="${score eq 'D'}">较差</c:when>
    <c:otherwise>成绩非法</c:otherwise>
    </c:choose>
    </body>
    </html>
  • 迭代操作
    c:forEach:用来遍历集合,属性:

    属性 作用
    items 指定要遍历的集合,它可以是用EL表达式取出来的元素
    var 把当前遍历的元素放入指定的page域中。var的值是key,遍历的元素是value
    注意:var不支持EL表达式,只能是字符串常量
    begin 开始遍历的索引
    end 结束遍历的索引
    step 步长,i+=step
    varStatus 它是一个计数器对象,有两个属性,一个是用于记录索引,一个是用于计数。索引是从0开始,计数是从1开始
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    <%@ page import="java.util.ArrayList" %>
    <%@ page contentType="text/html;charset=UTF-8" language="java" %>
    <%@taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c"%>
    <html>
    <head>
    <title>循环</title>
    </head>
    <body>
    <%--向域对象中添加集合--%>
    <%
    ArrayList<String> list = new ArrayList<>();
    list.add("aa");
    list.add("bb");
    list.add("cc");
    list.add("dd");
    pageContext.setAttribute("list",list);
    %>
    <%--遍历集合--%>
    <c:forEach items="${list}" var="str">
    ${str} <br>
    </c:forEach>
    </body>
    </html>

Filter

过滤器

Filter:过滤器,是 JavaWeb 三大组件之一,另外两个是 Servlet 和 Listener

工作流程:在程序访问服务器资源时,当一个请求到来,服务器首先判断是否有过滤器与去请求资源相关联,如果有过滤器可以将请求拦截下来,完成一些特定的功能,再由过滤器决定是否交给请求资源,如果没有就直接请求资源,响应同理

作用:过滤器一般用于完成通用的操作,例如:登录验证、统一编码处理、敏感字符过滤等


相关类

Filter

Filter是一个接口,如果想实现过滤器的功能,必须实现该接口

  • 核心方法

    方法 说明
    void init(FilterConfig filterConfig) 初始化,开启过滤器
    void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) 对请求资源和响应资源过滤
    void destroy() 销毁过滤器
  • 配置方式

    注解方式

    1
    2
    @WebFilter("/*")
    ()内填拦截路径,/*代表全部路径

    配置文件

    1
    2
    3
    4
    5
    6
    7
    8
    <filter>
    <filter-name>filterDemo01</filter-name>
    <filter-class>filter.FilterDemo01</filter-class>
    </filter>
    <filter-mapping>
    <filter-name>filterDemo01</filter-name>
    <url-pattern>/*</url-pattern>
    </filter-mapping>

FilterChain

  • FilterChain 是一个接口,代表过滤器对象。由Servlet容器提供实现类对象,直接使用即可。

  • 过滤器可以定义多个,就会组成过滤器链

  • 核心方法:void doFilter(ServletRequest request, ServletResponse response) 用来放行方法

    如果有多个过滤器,在第一个过滤器中调用下一个过滤器,以此类推,直到到达最终访问资源。
    如果只有一个过滤器,放行时就会直接到达最终访问资源。

FilterConfig

FilterConfig 是一个接口,代表过滤器的配置对象,可以加载一些初始化参数

方法 作用
String getFilterName() 获取过滤器对象名称
String getInitParameter(String name) 获取指定名称的初始化参数的值,不存在返回null
Enumeration getInitParameterNames() 获取所有参数的名称
ServletContext getServletContext() 获取应用上下文对象

Filter使用

设置页面编码

请求先被过滤器拦截进行相关操作

过滤器放行之后执行完目标资源,仍会回到过滤器中

  • Filter 代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @WebFilter("/*")
    public class FilterDemo01 implements Filter{
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
    System.out.println("filterDemo01拦截到请求...");
    //处理乱码
    servletResponse.setContentType("text/html;charset=UTF-8");
    //过滤器放行
    filterChain.doFilter(servletRequest,servletResponse);
    System.out.println("filterDemo1放行之后,又回到了doFilter方法");
    }
    }
  • Servlet 代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @WebServlet("/servletDemo01")
    public class ServletDemo01 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    System.out.println("servletDemo01执行了...");
    resp.getWriter().write("servletDemo01执行了...");
    }
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    doGet(req,resp);
    }
    }
  • 控制台输出:

    1
    2
    3
    filterDemo01拦截到请求...
    servletDemo01执行了...
    filterDemo1放行之后,又回到了doFilter方法

多过滤器顺序

多个过滤器使用的顺序,取决于过滤器映射的顺序。

  • 两个 Filter 代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public class FilterDemo01 implements Filter{
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
    System.out.println("filterDemo01执行了...");
    filterChain.doFilter(servletRequest,servletResponse);
    }
    }
    public class FilterDemo02 implements Filter{
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
    System.out.println("filterDemo02执行了...");
    filterChain.doFilter(servletRequest,servletResponse);
    }
    }
  • Servlet代码:System.out.println("servletDemo02执行了...");

  • web.xml配置:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <filter>
    <filter-name>filterDemo01</filter-name>
    <filter-class>filter.FilterDemo01</filter-class>
    </filter>
    <filter-mapping>
    <filter-name>filterDemo01</filter-name>
    <url-pattern>/*</url-pattern>
    </filter-mapping>
    <filter>
    <filter-name>filterDemo02</filter-name>
    <filter-class>filter.FilterDemo02</filter-class>
    </filter>
    <filter-mapping>
    <filter-name>filterDemo02</filter-name>
    <url-pattern>/*</url-pattern>
    </filter-mapping>
  • 控制台输出:

    1
    2
    3
    filterDemo01执行了
    filterDemo02执行了
    servletDemo02执行了...

在过滤器的配置中,有过滤器的声明和过滤器的映射两部分,到底是声明决定顺序,还是映射决定顺序呢?

答案是:<filter-mapping>的配置前后顺序决定过滤器的调用顺序,也就是由映射配置顺序决定。


Filter生命周期

创建:当应用加载时实例化对象并执行init()初始化方法

服务:对象提供服务的过程,执行doFilter()方法

销毁:当应用卸载时或服务器停止时对象销毁,执行destroy()方法

  • Filter代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    @WebFilter("/*")
    public class FilterDemo03 implements Filter{
    /*
    初始化方法
    */
    @Override
    public void init(FilterConfig filterConfig) {
    System.out.println("对象初始化成功了...");
    }
    /*
    提供服务方法
    */
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
    System.out.println("filterDemo03执行了...");
    //过滤器放行
    filterChain.doFilter(servletRequest,servletResponse);
    }
    /*
    对象销毁方法,关闭Tomcat服务器
    */
    @Override
    public void destroy() {
    System.out.println("对象销毁了...");
    }
    }

  • Servlet 代码:System.out.println("servletDemo03执行了...");

  • 控制台输出:

    1
    2
    3
    4
    对象初始化成功了...
    filterDemo03执行了...
    servletDemo03执行了...
    对象销毁了

FilterConfig使用

Filter初始化函数init的参数是FilterConfig 对象

  • Filter代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    public class FilterDemo04 implements Filter{

    //初始化方法
    @Override
    public void init(FilterConfig filterConfig) {
    System.out.println("对象初始化成功了...");

    //获取过滤器名称
    String filterName = filterConfig.getFilterName();
    System.out.println(filterName);

    //根据name获取value
    String username = filterConfig.getInitParameter("username");
    System.out.println(username);
    }
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
    System.out.println("filterDemo04执行了...");
    filterChain.doFilter(servletRequest,servletResponse);
    }
    @Override
    public void destroy() {}
    }
  • web.xml配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <filter>
    <filter-name>filterDemo04</filter-name>
    <filter-class>filter.FilterDemo04</filter-class>
    <init-param>
    <param-name>username</param-name>
    <param-value>zhangsan</param-value>
    </init-param>
    </filter>
    <filter-mapping>
    <filter-name>filterDemo04</filter-name>
    <url-pattern>/*</url-pattern>
    </filter-mapping>
  • 控制台输出:

    1
    2
    3
    对象初始化成功了...
    filterDemo04
    zhangsan

Filter案例

在访问html,js,image时,不需要每次都重新发送请求读取资源,就可以通过设置响应消息头的方式,设置缓存时间。但是如果每个Servlet都编写相同的代码,显然不符合我们统一调用和维护的理念。

静态资源设置缓存时间:html设置为1小时,js设置为2小时,css设置为3小时

  • 配置过滤器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    <filter>
    <filter-name>StaticResourceNeedCacheFilter</filter-name>
    <filter-class>filter.StaticResourceNeedCacheFilter</filter-class>
    <init-param>
    <param-name>html</param-name>
    <param-value>3</param-value>
    </init-param>
    <init-param>
    <param-name>js</param-name>
    <param-value>4</param-value>
    </init-param>
    <init-param>
    <param-name>css</param-name>
    <param-value>5</param-value>
    </init-param>
    </filter>
    <filter-mapping>
    <filter-name>StaticResourceNeedCacheFilter</filter-name>
    <url-pattern>*.html</url-pattern>
    </filter-mapping>
    <filter-mapping>
    <filter-name>StaticResourceNeedCacheFilter</filter-name>
    <url-pattern>*.js</url-pattern>
    </filter-mapping>
    <filter-mapping>
    <filter-name>StaticResourceNeedCacheFilter</filter-name>
    <url-pattern>*.css</url-pattern>
    </filter-mapping>
  • 编写过滤器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    public class StaticResourceNeedCacheFilter implements Filter {
    private FilterConfig filterConfig;//获取初始化参数
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
    this.filterConfig = filterConfig;
    }

    @Override
    public void doFilter(ServletRequest req, ServletResponse res,
    FilterChain chain) throws IOException, ServletException {
    //1.把doFilter的请求和响应对象转换成跟http协议有关的对象
    HttpServletRequest request;
    HttpServletResponse response;
    try {
    request = (HttpServletRequest) req;
    response = (HttpServletResponse) res;
    } catch (ClassCastException e) {
    throw new ServletException("non-HTTP request or response");
    }
    //2.获取请求资源URI
    String uri = request.getRequestURI();
    //3.得到请求资源到底是什么类型
    String extend = uri.substring(uri.lastIndexOf(".")+1);//我们只需要判断它是不是html,css,js。其他的不管
    //4.判断到底是什么类型的资源
    long time = 60*60*1000;
    if("html".equals(extend)){
    //html 缓存1小时
    String html = filterConfig.getInitParameter("html");
    time = time*Long.parseLong(html);
    }else if("js".equals(extend)){
    //js 缓存2小时
    String js = filterConfig.getInitParameter("js");
    time = time*Long.parseLong(js);
    }else if("css".equals(extend)){
    //css 缓存3小时
    String css = filterConfig.getInitParameter("css");
    time = time*Long.parseLong(css);

    }
    //5.设置响应消息头
    response.setDateHeader("Expires", System.currentTimeMillis()+time);
    //6.放行
    chain.doFilter(request, response);
    }

    @Override
    public void destroy() {}
    }

拦截行为

Filter过滤器默认拦截的是请求,但是在实际开发中,我们还有请求转发和请求包含,以及由服务器触发调用的全局错误页面。默认情况下过滤器是不参与过滤的,需要配置web.xml

开启功能后,当访问页面发生相关行为后,会执行过滤器的操作

五种拦截行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!--配置过滤器-->
<filter>
<filter-name>FilterDemo5</filter-name>
<filter-class>filter.FilterDemo5</filter-class>
<!--配置开启异步支持,当dispatcher配置ASYNC时,需要配置此行-->
<async-supported>true</async-supported>
</filter>
<filter-mapping>
<filter-name>FilterDemo5</filter-name>
<url-pattern>/error.jsp</url-pattern>
<!--<url-pattern>/index.jsp</url-pattern>-->
<!--过滤请求:默认值。-->
<dispatcher>REQUEST</dispatcher>
<!--过滤全局错误页面:开启后,当由服务器调用全局错误页面时,过滤器工作-->
<dispatcher>ERROR</dispatcher>
<!--过滤请求转发:开启后,当请求转发时,过滤器工作。-->
<dispatcher>FORWARD</dispatcher>
<!--过滤请求包含:当请求包含时,过滤器工作。它只能过滤动态包含,jsp的include指令是静态包含-->
<dispatcher>INCLUDE</dispatcher>
<!--过滤异步类型,它要求我们在filter标签中配置开启异步支持-->
<dispatcher>ASYNC</dispatcher>
</filter-mapping>

  • web.xml:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <filter>
    <filter-name>FilterDemo5</filter-name>
    <filter-class>filter.FilterDemo5</filter-class>
    <!--配置开启异步支持,当dispatcher配置ASYNC时,需要配置此行-->
    <async-supported>true</async-supported>
    </filter>
    <filter-mapping>
    <filter-name>FilterDemo5</filter-name>
    <url-pattern>/error.jsp</url-pattern>
    <dispatcher>ERROR</dispatcher>
    <filter-mapping>
  • ServletDemo03:

    1
    2
    3
    4
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    System.out.println("servletDemo03执行了...");
    int i = 1/ 0;
    }
  • FilterDemo05:

    1
    2
    3
    4
    5
    6
    7
    8
    public class FilterDemo05 implements Filter{
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
    System.out.println("filterDemo05执行了...");
    //放行
    filterChain.doFilter(servletRequest,servletResponse);
    }
    }
  • 访问URL:http://localhost:8080/filter/servletDemo03

  • 控制台输出(注意输出顺序):

    1
    2
    servletDemo03执行了...
    filterDemo05执行了...

对比Servlet

方法/类型 Servlet Filter 备注
初始化 方法 void init(ServletConfig); void init(FilterConfig); 几乎一样,都是在web.xml中配置参数,用该对象的方法可以获取到。
提供服务方法 void service(request,response); void dofilter(request,response,FilterChain) Filter比Servlet多了一个FilterChain,它不仅能完成Servlet的功能,而且还可以决定程序是否能继续执行。所以过滤器比Servlet更为强大。 在Struts2中,核心控制器就是一个过滤器。
销毁方法 void destroy(); void destroy(); 方法/类型

Listener

观察者设计者

所有的监听器都是基于观察者设计模式的。

观察者模式通常由以下三部分组成:

  • 事件源:触发事件的对象。

  • 事件:触发的动作,里面封装了事件源。

  • 监听器:当事件源触发事件后,可以完成的功能。一般是一个接口,由使用者来实现。(此处的思想还涉及了一个策略模式)


监听器分类

在程序当中,我们可以对:对象的创建销毁、域对象中属性的变化、会话相关内容进行监听。

Servlet规范中共计8个监听器,监听器都是以接口形式提供,具体功能需要我们自己完成

监听对象

  • ServletContextListener:用于监听ServletContext对象的创建和销毁

    方法 作用
    void contextInitialized(ServletContextEvent sce) 对象创建时执行该方法
    void contextDestroyed(ServletContextEvent sce) 对象销毁时执行该方法

    参数ServletContextEvent 代表事件对象,事件对象中封装了事件源ServletContext,真正的事件指的是创建或者销毁ServletContext对象的操作

  • HttpSessionListener:用于监听HttpSession对象的创建和销毁

    方法 作用
    void sessionCreated(HttpSessionEvent se) 对象创建时执行该方法
    void sessionDestroyed(HttpSessionEvent se) 对象销毁时执行该方法

    参数HttpSessionEvent 代表事件对象,事件对象中封装了事件源HttpSession,真正的事件指的是创建或者销毁HttpSession对象的操作

  • ServletRequestListener:用于监听ServletRequest对象的创建和销毁

    方法 作用
    void requestInitialized(ServletRequestEvent sre) 对象创建时执行该方法
    void requestDestroyed(ServletRequestEvent sre) 对象销毁时执行该方法

    参数ServletRequestEvent 代表事件对象,事件对象中封装了事件源ServletRequest,真正的事件指的是创建或者销毁ServletRequest对象的操作


监听域对象属性

  • ServletContextAttributeListener:用于监听ServletContext应用域中属性的变化

    方法 作用
    void attributeAdded(ServletContextAttributeEvent event) 域中添加属性时执行该方法
    void attributeRemoved(ServletContextAttributeEvent event) 域中移除属性时执行该方法
    void attributeReplaced(ServletContextAttributeEvent event) 域中替换属性时执行该方法

    参数ServletContextAttributeEvent 代表事件对象,事件对象中封装了事件源ServletContext,真正的事件指的是添加、移除、替换应用域中属性的操作

  • HttpSessionAttributeListener:用于监听HttpSession会话域中属性的变化

    方法 作用
    void attributeAdded(HttpSessionBindingEvent event) 域中添加属性时执行该方法
    void attributeRemoved(HttpSessionBindingEvent event) 域中移除属性时执行该方法
    void attributeReplaced(HttpSessionBindingEvent event) 域中替换属性时执行该方法

    参数HttpSessionBindingEvent 代表事件对象,事件对象中封装了事件源HttpSession,真正的事件指的是添加、移除、替换应用域中属性的操作

  • ServletRequestAttributeListener:用于监听ServletRequest请求域中属性的变化

    方法 作用
    void attributeAdded(ServletRequestAttributeEvent srae) 域中添加属性时执行该方法
    void attributeRemoved(ServletRequestAttributeEvent srae) 域中移除属性时执行该方法
    void attributeReplaced(ServletRequestAttributeEvent srae) 域中替换属性时执行该方法

    参数ServletRequestAttributeEvent 代表事件对象,事件对象中封装了事件源ServletRequest,真正的事件指的是添加、移除、替换应用域中属性的操作

  • 页面域对象没有监听器


感知型监听器

监听会话相关的感知型监听器,和会话域相关的两个感知型监听器是无需配置(注解)的,可以直接编写代码

  • HttpSessionBindingListener:用于感知对象和会话域绑定的监听器

    方法 作用
    void valueBound(HttpSessionBindingEvent event) 数据添加到会话域中(绑定)时执行该方法
    void valueUnbound(HttpSessionBindingEvent event) 数据从会话域中移除(解绑)时执行该方法

    参数HttpSessionBindingEvent 代表事件对象,事件对象中封装了事件源HttpSession,真正的事件指的是添加、移除、替换应用域中属性的操作

  • HttpSessionActivationListener:用于感知会话域中对象和钝化和活化的监听器

    方法 作用
    void sessionWillPassivate(HttpSessionEvent se) 会话域中数据钝化时执行该方法
    void sessionDidActivate(HttpSessionEvent se) 会话域中数据活化时执行该方法

监听器使用

ServletContextListener

ServletContext对象的创建和销毁的监听器

注解方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@WebListener
public class ServletContextListenerDemo implements ServletContextListener {
//创建时执行此方法
@Override
public void contextInitialized(ServletContextEvent sce) {
System.out.println("监听到对象的创建....");//启动服务器就创建

ServletContext servletContext = sce.getServletContext();
System.out.println(servletContext);
}
//销毁时执行的方法
@Override
public void contextDestroyed(ServletContextEvent sce) {
System.out.println("监听到对象的销毁...");//关闭服务器就销毁
}
}

配置web.xml

1
2
3
4
5
6
<web-app>
<!--配置监听器-->
<listener>
<listener-class>listener.ServletContextAttributeListenerDemo</listener-class>
</listener>
</web-app>

ServletContextAttributeListener

应用域对象中的属性变化的监听器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class ServletContextAttributeListenerDemo implements ServletContextAttributeListener{
/*
向应用域对象中添加属性时执行此方法
*/
@Override
public void attributeAdded(ServletContextAttributeEvent scae) {
System.out.println("监听到了属性的添加...");

//获取应用域对象
ServletContext servletContext = scae.getServletContext();
//获取属性
Object value = servletContext.getAttribute("username");
System.out.println(value);//zhangsan
}

/*
向应用域对象中替换属性时执行此方法
*/
@Override
public void attributeReplaced(ServletContextAttributeEvent scae) {
System.out.println("监听到了属性的替换...");

//获取应用域对象
ServletContext servletContext = scae.getServletContext();
//获取属性
Object value = servletContext.getAttribute("username");
System.out.println(value);//lisi
}

/*
向应用域对象中移除属性时执行此方法
*/
@Override
public void attributeRemoved(ServletContextAttributeEvent scae) {
System.out.println("监听到了属性的移除...");

//获取应用域对象
ServletContext servletContext = scae.getServletContext();
//获取属性
Object value = servletContext.getAttribute("username");
System.out.println(value);//null
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class ServletContextListenerDemo implements ServletContextListener{
//ServletContext对象创建的时候执行此方法
@Override
public void contextInitialized(ServletContextEvent sce) {
System.out.println("监听到了对象的创建...");
//获取对象
ServletContext servletContext = sce.getServletContext();

//添加属性
servletContext.setAttribute("username","zhangsan");

//替换属性
servletContext.setAttribute("username","lisi");

//移除属性
servletContext.removeAttribute("username");
}

//ServletContext对象销毁的时候执行此方法
@Override
public void contextDestroyed(ServletContextEvent sce) {
System.out.println("监听到了对象的销毁...");
}
}

控制台输出:

1
2
3
4
5
6
7
监听到了对象的创建...
监听到了属性的添加...
zhangsan
监听到了属性的替换
lisi
监听到属性的移除
null

JS

概述

JavaScript 是一种客户端脚本语言。运行在客户端浏览器中,每一个浏览器都具备解析 JavaScript 的引擎。

脚本语言:不需要编译,就可以被浏览器直接解析执行了。

作用:增强用户和 HTML 页面的交互过程,让页面产生动态效果,增强用户的体验。

组成部分:ECMAScript、DOM、BOM

开发环境搭建:安装Node.js,是JavaScript运行环境


语法

引入

引入HTML文件

  • 内部方式:

1
2
3
4
5
title: 数据库
date: 2022-01-01 00:00:00
tags: DataSource
categories: DataSource
comment

MySQL

简介

数据库

数据库:DataBase,简称 DB,存储和管理数据的仓库

数据库的优势:

  • 可以持久化存储数据
  • 方便存储和管理数据
  • 使用了统一的方式操作数据库 SQL

数据库、数据表、数据的关系介绍:

  • 数据库

    • 用于存储和管理数据的仓库
    • 一个库中可以包含多个数据表
  • 数据表

    • 数据库最重要的组成部分之一
    • 由纵向的列和横向的行组成(类似 excel 表格)
    • 可以指定列名、数据类型、约束等
    • 一个表中可以存储多条数据
  • 数据:想要永久化存储的数据

参考视频:https://www.bilibili.com/video/BV1zJ411M7TB

参考专栏:https://time.geekbang.org/column/intro/139

参考书籍:https://book.douban.com/subject/35231266/


MySQL

MySQL 数据库是一个最流行的关系型数据库管理系统之一,关系型数据库是将数据保存在不同的数据表中,而且表与表之间可以有关联关系,提高了灵活性

缺点:数据存储在磁盘中,导致读写性能差,而且数据关系复杂,扩展性差

MySQL 所使用的 SQL 语句是用于访问数据库最常用的标准化语言

MySQL 配置:

  • MySQL 安装:https://www.jianshu.com/p/ba48f1e386f0

  • MySQL 配置:

    • 修改 MySQL 默认字符集:安装 MySQL 之后第一件事就是修改字符集编码

      1
      2
      3
      4
      5
      6
      7
      8
      9
      vim /etc/mysql/my.cnf

      添加如下内容:
      [mysqld]
      character-set-server=utf8
      collation-server=utf8_general_ci

      [client]
      default-character-set=utf8
    • 启动 MySQL 服务:

      1
      systemctl start/restart mysql
    • 登录 MySQL:

      1
      2
      3
      mysql -u root -p  敲回车,输入密码
      初始密码查看:cat /var/log/mysqld.log
      在root@localhost: 后面的就是初始密码
    • 查看默认字符集命令:

      1
      SHOW VARIABLES LIKE 'char%';
    • 修改MySQL登录密码:

      1
      2
      3
      4
      set global validate_password_policy=0;
      set global validate_password_length=1;

      set password=password('密码');
    • 授予远程连接权限(MySQL 内输入):

      1
      2
      3
      4
      -- 授权
      grant all privileges on *.* to 'root' @'%' identified by '密码';
      -- 刷新
      flush privileges;
  • 修改 MySQL 绑定 IP:

    1
    2
    3
    4
    cd /etc/mysql/mysql.conf.d
    sudo chmod 666 mysqld.cnf
    vim mysqld.cnf
    # bind-address = 127.0.0.1注释该行
  • 关闭 Linux 防火墙

    1
    2
    systemctl stop firewalld.service
    # 放行3306端口

体系架构

整体架构

体系结构详解:

  • 第一层:网络连接层
    • 一些客户端和链接服务,包含本地 Socket 通信和大多数基于客户端/服务端工具实现的 TCP/IP 通信,主要完成一些类似于连接处理、授权认证、及相关的安全方案
    • 在该层上引入了连接池 Connection Pool 的概念,管理缓冲用户连接,线程处理等需要缓存的需求
    • 在该层上实现基于 SSL 的安全链接,服务器也会为安全接入的每个客户端验证它所具有的操作权限
  • 第二层:核心服务层
    • 查询缓存、分析器、优化器、执行器等,涵盖 MySQL 的大多数核心服务功能,所有的内置函数(日期、数学、加密函数等)
      • Management Serveices & Utilities:系统管理和控制工具,备份、安全、复制、集群等
      • SQL Interface:接受用户的 SQL 命令,并且返回用户需要查询的结果
      • Parser:SQL 语句分析器
      • Optimizer:查询优化器
      • Caches & Buffers:查询缓存,服务器会查询内部的缓存,如果缓存空间足够大,可以在大量读操作的环境中提升系统性能
    • 所有跨存储引擎的功能在这一层实现,如存储过程、触发器、视图等
    • 在该层服务器会解析查询并创建相应的内部解析树,并对其完成相应的优化如确定表的查询顺序,是否利用索引等, 最后生成相应的执行操作
    • MySQL 中服务器层不管理事务,事务是由存储引擎实现的
  • 第三层:存储引擎层
    • Pluggable Storage Engines:存储引擎接口,MySQL 区别于其他数据库的重要特点就是其存储引擎的架构模式是插件式的(存储引擎是基于表的,而不是数据库)
    • 存储引擎真正的负责了 MySQL 中数据的存储和提取,服务器通过 API 和存储引擎进行通信
    • 不同的存储引擎具有不同的功能,共用一个 Server 层,可以根据开发的需要,来选取合适的存储引擎
  • 第四层:系统文件层
    • 数据存储层,主要是将数据存储在文件系统之上,并完成与存储引擎的交互
    • File System:文件系统,保存配置文件、数据文件、日志文件、错误文件、二进制文件等


建立连接

连接器

池化技术:对于访问数据库来说,建立连接的代价是比较昂贵的,因为每个连接对应一个用来交互的线程,频繁的创建关闭连接比较耗费资源,有必要建立数据库连接池,以提高访问的性能

连接建立 TCP 以后需要做权限验证,验证成功后可以进行执行 SQL。如果这时管理员账号对这个用户的权限做了修改,也不会影响已经存在连接的权限,只有再新建的连接才会使用新的权限设置

MySQL 服务器可以同时和多个客户端进行交互,所以要保证每个连接会话的隔离性(事务机制部分详解)

整体的执行流程:


权限信息

grant 语句会同时修改数据表和内存,判断权限的时候使用的是内存数据

flush privileges 语句本身会用数据表(磁盘)的数据重建一份内存权限数据,所以在权限数据可能存在不一致的情况下使用,这种不一致往往是由于直接用 DML 语句操作系统权限表导致的,所以尽量不要使用这类语句


连接状态

客户端如果长时间没有操作,连接器就会自动断开,时间是由参数 wait_timeout 控制的,默认值是 8 小时。如果在连接被断开之后,客户端再次发送请求的话,就会收到一个错误提醒:Lost connection to MySQL server during query

数据库里面,长连接是指连接成功后,如果客户端持续有请求,则一直使用同一个连接;短连接则是指每次执行完很少的几次查询就断开连接,下次查询再重新建立一个

为了减少连接的创建,推荐使用长连接,但是过多的长连接会造成 OOM,解决方案:

  • 定期断开长连接,使用一段时间,或者程序里面判断执行过一个占用内存的大查询后,断开连接,之后要查询再重连

    1
    KILL CONNECTION id
  • MySQL 5.7 版本,可以在每次执行一个比较大的操作后,通过执行 mysql_reset_connection 来重新初始化连接资源,这个过程不需要重连和重新做权限验证,但是会将连接恢复到刚刚创建完时的状态

SHOW PROCESSLIST:查看当前 MySQL 在进行的线程,可以实时地查看 SQL 的执行情况,其中的 Command 列显示为 Sleep 的这一行,就表示现在系统里面有一个空闲连接

参数 含义
ID 用户登录 mysql 时系统分配的 connection_id,可以使用函数 connection_id() 查看
User 显示当前用户,如果不是 root,这个命令就只显示用户权限范围的 sql 语句
Host 显示这个语句是从哪个 ip 的哪个端口上发的,可以用来跟踪出现问题语句的用户
db 显示这个进程目前连接的是哪个数据库
Command 显示当前连接的执行的命令,一般取值为休眠 Sleep、查询 Query、连接 Connect 等
Time 显示这个状态持续的时间,单位是秒
State 显示使用当前连接的 sql 语句的状态,以查询为例,需要经过 copying to tmp table、sorting result、sending data等状态才可以完成
Info 显示执行的 sql 语句,是判断问题语句的一个重要依据

Sending data 状态表示 MySQL 线程开始访问数据行并把结果返回给客户端,而不仅仅只是返回给客户端,是处于执行器过程中的任意阶段。由于在 Sending data 状态下,MySQL 线程需要做大量磁盘读取操作,所以是整个查询中耗时最长的状态


执行流程

查询缓存

工作流程

当执行完全相同的 SQL 语句的时候,服务器就会直接从缓存中读取结果,当数据被修改,之前的缓存会失效,修改比较频繁的表不适合做查询缓存

查询过程:

  1. 客户端发送一条查询给服务器
  2. 服务器先会检查查询缓存,如果命中了缓存,则立即返回存储在缓存中的结果(一般是 K-V 键值对),否则进入下一阶段
  3. 分析器进行 SQL 分析,再由优化器生成对应的执行计划
  4. MySQL 根据优化器生成的执行计划,调用存储引擎的 API 来执行查询
  5. 将结果返回给客户端

大多数情况下不建议使用查询缓存,因为查询缓存往往弊大于利

  • 查询缓存的失效非常频繁,只要有对一个表的更新,这个表上所有的查询缓存都会被清空。因此很可能费力地把结果存起来,还没使用就被一个更新全清空了,对于更新压力大的数据库来说,查询缓存的命中率会非常低
  • 除非业务就是有一张静态表,很长时间才会更新一次,比如一个系统配置表,那这张表上的查询才适合使用查询缓存

缓存配置
  1. 查看当前 MySQL 数据库是否支持查询缓存:

    1
    SHOW VARIABLES LIKE 'have_query_cache';	-- YES
  2. 查看当前 MySQL 是否开启了查询缓存:

    1
    SHOW VARIABLES LIKE 'query_cache_type';	-- OFF

    参数说明:

    • OFF 或 0:查询缓存功能关闭

    • ON 或 1:查询缓存功能打开,查询结果符合缓存条件即会缓存,否则不予缓存;可以显式指定 SQL_NO_CACHE 不予缓存

    • DEMAND 或 2:查询缓存功能按需进行,显式指定 SQL_CACHE 的 SELECT 语句才缓存,其它不予缓存

      1
      2
      SELECT SQL_CACHE id, name FROM customer; -- SQL_CACHE:查询结果可缓存
      SELECT SQL_NO_CACHE id, name FROM customer;-- SQL_NO_CACHE:不使用查询缓存
  3. 查看查询缓存的占用大小:

    1
    SHOW VARIABLES LIKE 'query_cache_size';-- 单位是字节 1048576 / 1024 = 1024 = 1KB
  4. 查看查询缓存的状态变量:

    1
    SHOW STATUS LIKE 'Qcache%';
    参数 含义
    Qcache_free_blocks 查询缓存中的可用内存块数
    Qcache_free_memory 查询缓存的可用内存量
    Qcache_hits 查询缓存命中数
    Qcache_inserts 添加到查询缓存的查询数
    Qcache_lowmen_prunes 由于内存不足而从查询缓存中删除的查询数
    Qcache_not_cached 非缓存查询的数量(由于 query_cache_type 设置而无法缓存或未缓存)
    Qcache_queries_in_cache 查询缓存中注册的查询数
    Qcache_total_blocks 查询缓存中的块总数
  5. 配置 my.cnf:

    1
    2
    3
    4
    sudo chmod 666 /etc/mysql/my.cnf
    vim my.cnf
    # mysqld中配置缓存
    query_cache_type=1

    重启服务既可生效,执行 SQL 语句进行验证 ,执行一条比较耗时的 SQL 语句,然后再多执行几次,查看后面几次的执行时间;获取通过查看查询缓存的缓存命中数,来判定是否走查询缓存


缓存失效

查询缓存失效的情况:

  • SQL 语句不一致,要想命中查询缓存,查询的 SQL 语句必须一致,因为缓存中 key 是查询的语句,value 是查询结构

    1
    2
    select count(*) from tb_item;
    Select count(*) from tb_item; -- 不走缓存,首字母不一致
  • 当查询语句中有一些不确定查询时,则不会缓存,比如:now()、current_date()、curdate()、curtime()、rand()、uuid()、user()、database()

    1
    2
    3
    SELECT * FROM tb_item WHERE updatetime < NOW() LIMIT 1;
    SELECT USER();
    SELECT DATABASE();
  • 不使用任何表查询语句:

    1
    SELECT 'A';
  • 查询 mysql、information_schema、performance_schema 等系统表时,不走查询缓存:

    1
    SELECT * FROM information_schema.engines;
  • 跨存储引擎的存储过程、触发器或存储函数的主体内执行的查询,缓存失效

  • 如果表更改,则使用该表的所有高速缓存查询都将变为无效并从高速缓存中删除,包括使用 MERGE 映射到已更改表的表的查询,比如:INSERT、UPDATE、DELETE、ALTER TABLE、DROP TABLE、DROP DATABASE


分析器

没有命中查询缓存,就开始了 SQL 的真正执行,分析器会对 SQL 语句做解析

1
SELECT * FROM t WHERE id = 1;

解析器:处理语法和解析查询,生成一课对应的解析树

  • 先做词法分析,输入的是由多个字符串和空格组成的一条 SQL 语句,MySQL 需要识别出里面的字符串分别是什么代表什么。从输入的 select 这个关键字识别出来这是一个查询语句;把字符串 t 识别成 表名 t,把字符串 id 识别成列 id
  • 然后做语法分析,根据词法分析的结果,语法分析器会根据语法规则,判断你输入的这个 SQL 语句是否满足 MySQL 语法。如果语句不对,就会收到 You have an error in your SQL syntax 的错误提醒

预处理器:进一步检查解析树的合法性,比如数据表和数据列是否存在、别名是否有歧义等


优化器

成本分析

优化器是在表里面有多个索引的时候,决定使用哪个索引;或者在一个语句有多表关联(join)的时候,决定各个表的连接顺序

  • 根据搜索条件找出所有可能的使用的索引
  • 成本分析,执行成本由 I/O 成本和 CPU 成本组成,计算全表扫描和使用不同索引执行 SQL 的代价
  • 找到一个最优的执行方案,用最小的代价去执行语句

在数据库里面,扫描行数是影响执行代价的因素之一,扫描的行数越少意味着访问磁盘的次数越少,消耗的 CPU 资源越少,优化器还会结合是否使用临时表、是否排序等因素进行综合判断


统计数据

MySQL 中保存着两种统计数据:

  • innodb_table_stats 存储了表的统计数据,每一条记录对应着一个表的统计数据
  • innodb_index_stats 存储了索引的统计数据,每一条记录对应着一个索引的一个统计项的数据

MySQL 在真正执行语句之前,并不能精确地知道满足条件的记录有多少条,只能根据统计信息来估算记录,统计信息就是索引的区分度,一个索引上不同的值的个数(比如性别只能是男女,就是 2 ),称之为基数(cardinality),基数越大说明区分度越好

通过采样统计来获取基数,InnoDB 默认会选择 N 个数据页,统计这些页面上的不同值得到一个平均值,然后乘以这个索引的页面数,就得到了这个索引的基数

在 MySQL 中,有两种存储统计数据的方式,可以通过设置参数 innodb_stats_persistent 的值来选择:

  • ON:表示统计信息会持久化存储(默认),采样页数 N 默认为 20,可以通过 innodb_stats_persistent_sample_pages 指定,页数越多统计的数据越准确,但消耗的资源更大
  • OFF:表示统计信息只存储在内存,采样页数 N 默认为 8,也可以通过系统变量设置(不推荐,每次重新计算浪费资源)

数据表是会持续更新的,两种统计信息的更新方式:

  • 设置 innodb_stats_auto_recalc 为 1,当发生变动的记录数量超过表大小的 10% 时,自动触发重新计算,不过是异步进行
  • 调用 ANALYZE TABLE t 手动更新统计信息,只对信息做重新统计(不是重建表),没有修改数据,这个过程中加了 MDL 读锁并且是同步进行,所以会暂时阻塞系统

EXPLAIN 执行计划在优化器阶段生成,如果 explain 的结果预估的 rows 值跟实际情况差距比较大,可以执行 analyze 命令重新修正信息


错选索引

采样统计本身是估算数据,或者 SQL 语句中的字段选择有问题时,可能导致 MySQL 没有选择正确的执行索引

解决方法:

  • 采用 force index 强行选择一个索引

    1
    SELECT * FROM user FORCE INDEX(name) WHERE NAME='seazean';
  • 可以考虑修改 SQL 语句,引导 MySQL 使用期望的索引

  • 新建一个更合适的索引,来提供给优化器做选择,或删掉误用的索引


执行器

开始执行的时候,要先判断一下当前连接对表有没有执行查询的权限,如果没有就会返回没有权限的错误,在工程实现上,如果命中查询缓存,会在查询缓存返回结果的时候,做权限验证。如果有权限,就打开表继续执行,执行器就会根据表的引擎定义,去使用这个引擎提供的接口


引擎层

Server 层和存储引擎层的交互是以记录为单位的,存储引擎会将单条记录返回给 Server 层做进一步处理,并不是直接返回所有的记录

工作流程:

  • 首先根据二级索引选择扫描范围,获取第一条符合二级索引条件的记录,进行回表查询,将聚簇索引的记录返回 Server 层,由 Server 判断记录是否符合要求
  • 然后在二级索引上继续扫描下一个符合条件的记录

推荐阅读:https://mp.weixin.qq.com/s/YZ-LckObephrP1f15mzHpA


终止流程

终止语句

终止线程中正在执行的语句:

1
KILL QUERY thread_id

KILL 不是马上终止的意思,而是告诉执行线程这条语句已经不需要继续执行,可以开始执行停止的逻辑(类似于打断)。因为对表做增删改查操作,会在表上加 MDL 读锁,如果线程被 KILL 时就直接终止,那这个 MDL 读锁就没机会被释放了

命令 KILL QUERYthread_id_A 的执行流程:

  • 把 session A 的运行状态改成 THD::KILL_QUERY(将变量 killed 赋值为 THD::KILL_QUERY)
  • 给 session A 的执行线程发一个信号,让 session A 来处理这个 THD::KILL_QUERY 状态

会话处于等待状态(锁阻塞),必须满足是一个可以被唤醒的等待,必须有机会去判断线程的状态,如果不满足就会造成 KILL 失败

典型场景:innodb_thread_concurrency 为 2,代表并发线程上限数设置为 2

  • session A 执行事务,session B 执行事务,达到线程上限;此时 session C 执行事务会阻塞等待,session D 执行 kill query C 无效
  • C 的逻辑是每 10 毫秒判断是否可以进入 InnoDB 执行,如果不行就调用 nanosleep 函数进入 sleep 状态,没有去判断线程状态

补充:执行 Ctrl+C 的时候,是 MySQL 客户端另外启动一个连接,然后发送一个 KILL QUERY 命令


终止连接

断开线程的连接:

1
KILL CONNECTION id

断开连接后执行 SHOW PROCESSLIST 命令,如果这条语句的 Command 列显示 Killed,代表线程的状态是 KILL_CONNECTION,说明这个线程有语句正在执行,当前状态是停止语句执行中,终止逻辑耗时较长

  • 超大事务执行期间被 KILL,这时回滚操作需要对事务执行期间生成的所有新数据版本做回收操作,耗时很长
  • 大查询回滚,如果查询过程中生成了比较大的临时文件,删除临时文件可能需要等待 IO 资源,导致耗时较长
  • DDL 命令执行到最后阶段被 KILL,需要删除中间过程的临时文件,也可能受 IO 资源影响耗时较久

总结:KILL CONNECTION 本质上只是把客户端的 SQL 连接断开,后面的终止流程还是要走 KILL QUERY

一个事务被 KILL 之后,持续处于回滚状态,不应该强行重启整个 MySQL 进程,应该等待事务自己执行完成,因为重启后依然继续做回滚操作的逻辑


常用工具

mysql

mysql 不是指 mysql 服务,而是指 mysql 的客户端工具

1
mysql [options] [database]
  • -u –user=name:指定用户名
  • -p –password[=name]:指定密码
  • -h –host=name:指定服务器IP或域名
  • -P –port=#:指定连接端口
  • -e –execute=name:执行SQL语句并退出,在控制台执行SQL语句,而不用连接到数据库执行

示例:

1
2
mysql -h 127.0.0.1 -P 3306 -u root -p
mysql -uroot -p2143 db01 -e "select * from tb_book";

admin

mysqladmin 是一个执行管理操作的客户端程序,用来检查服务器的配置和当前状态、创建并删除数据库等

通过 mysqladmin --help 指令查看帮助文档

1
mysqladmin -uroot -p2143 create 'test01';

binlog

服务器生成的日志文件以二进制格式保存,如果需要检查这些文本,就要使用 mysqlbinlog 日志管理工具

1
mysqlbinlog [options]  log-files1 log-files2 ...
  • -d –database=name:指定数据库名称,只列出指定的数据库相关操作

  • -o –offset=#:忽略掉日志中的前 n 行命令。

  • -r –result-file=name:将输出的文本格式日志输出到指定文件。

  • -s –short-form:显示简单格式,省略掉一些信息。

  • –start-datatime=date1 –stop-datetime=date2:指定日期间隔内的所有日志

  • –start-position=pos1 –stop-position=pos2:指定位置间隔内的所有日志


dump

命令介绍

mysqldump 客户端工具用来备份数据库或在不同数据库之间进行数据迁移,备份内容包含创建表,及插入表的 SQL 语句

1
2
3
mysqldump [options] db_name [tables]
mysqldump [options] --database/-B db1 [db2 db3...]
mysqldump [options] --all-databases/-A

连接选项:

  • -u –user=name:指定用户名
  • -p –password[=name]:指定密码
  • -h –host=name:指定服务器 IP 或域名
  • -P –port=#:指定连接端口

输出内容选项:

  • –add-drop-database:在每个数据库创建语句前加上 Drop database 语句
  • –add-drop-table:在每个表创建语句前加上 Drop table 语句 , 默认开启,不开启 (–skip-add-drop-table)
  • -n –no-create-db:不包含数据库的创建语句
  • -t –no-create-info:不包含数据表的创建语句
  • -d –no-data:不包含数据
  • -T, –tab=name:自动生成两个文件:一个 .sql 文件,创建表结构的语句;一个 .txt 文件,数据文件,相当于 select into outfile

示例:

1
2
mysqldump -uroot -p2143 db01 tb_book --add-drop-database --add-drop-table > a
mysqldump -uroot -p2143 -T /tmp test city

数据备份

命令行方式:

  • 备份命令:mysqldump -u root -p 数据库名称 > 文件保存路径
  • 恢复
    1. 登录MySQL数据库:mysql -u root p
    2. 删除已经备份的数据库
    3. 重新创建与备份数据库名称相同的数据库
    4. 使用该数据库
    5. 导入文件执行:source 备份文件全路径

更多方式参考:https://time.geekbang.org/column/article/81925

图形化界面:

  • 备份

    图形化界面备份

  • 恢复

    图形化界面恢复


import

mysqlimport 是客户端数据导入工具,用来导入mysqldump 加 -T 参数后导出的文本文件

1
mysqlimport [options]  db_name  textfile1  [textfile2...]

示例:

1
mysqlimport -uroot -p2143 test /tmp/city.txt

导入 sql 文件,可以使用 MySQL 中的 source 指令 :

1
source 文件全路径

show

mysqlshow 客户端对象查找工具,用来很快地查找存在哪些数据库、数据库中的表、表中的列或者索引

1
mysqlshow [options] [db_name [table_name [col_name]]]
  • –count:显示数据库及表的统计信息(数据库,表 均可以不指定)

  • -i:显示指定数据库或者指定表的状态信息

示例:

1
2
3
4
5
6
#查询每个数据库的表的数量及表中记录的数量
mysqlshow -uroot -p1234 --count
#查询test库中每个表中的字段书,及行数
mysqlshow -uroot -p1234 test --count
#查询test库中book表的详细情况
mysqlshow -uroot -p1234 test book --count

单表操作

SQL

  • SQL

    • Structured Query Language:结构化查询语言
    • 定义了操作所有关系型数据库的规则,每种数据库操作的方式可能会存在不一样的地方,称为“方言”
  • SQL 通用语法

    • SQL 语句可以单行或多行书写,以分号结尾
    • 可使用空格和缩进来增强语句的可读性。
    • MySQL 数据库的 SQL 语句不区分大小写,关键字建议使用大写
    • 数据库的注释:
      • 单行注释:– 注释内容 #注释内容(MySQL 特有)
      • 多行注释:/* 注释内容 */
  • SQL 分类

    • DDL(Data Definition Language)数据定义语言

      • 用来定义数据库对象:数据库,表,列等。关键字:create、drop,、alter 等
    • DML(Data Manipulation Language)数据操作语言

      • 用来对数据库中表的数据进行增删改。关键字:insert、delete、update 等
    • DQL(Data Query Language)数据查询语言

      • 用来查询数据库中表的记录(数据)。关键字:select、where 等
    • DCL(Data Control Language)数据控制语言

      • 用来定义数据库的访问权限和安全级别,及创建用户。关键字:grant, revoke等


DDL

数据库

  • R(Retrieve):查询

    • 查询所有数据库:

      1
      SHOW DATABASES;
    • 查询某个数据库的创建语句

      1
      2
      3
      SHOW CREATE DATABASE 数据库名称;  -- 标准语法

      SHOW CREATE DATABASE mysql; -- 查看mysql数据库的创建格式
  • C(Create):创建

    • 创建数据库

      1
      2
      3
      CREATE DATABASE 数据库名称;-- 标准语法

      CREATE DATABASE db1; -- 创建db1数据库
    • 创建数据库(判断,如果不存在则创建)

      1
      CREATE DATABASE IF NOT EXISTS 数据库名称;
    • 创建数据库,并指定字符集

      1
      CREATE DATABASE 数据库名称 CHARACTER SET 字符集名称;
    • 例如:创建db4数据库、如果不存在则创建,指定字符集为gbk

      1
      2
      3
      4
      5
      -- 创建db4数据库、如果不存在则创建,指定字符集为gbk
      CREATE DATABASE IF NOT EXISTS db4 CHARACTER SET gbk;

      -- 查看db4数据库的字符集
      SHOW CREATE DATABASE db4;
  • U(Update):修改

    • 修改数据库的字符集

      1
      ALTER DATABASE 数据库名称 CHARACTER SET 字符集名称;
    • 常用字符集:

      1
      2
      3
      4
      5
      6
      7
      --查询所有支持的字符集
      SHOW CHARSET;
      --查看所有支持的校对规则
      SHOW COLLATION;

      -- 字符集: utf8,latinI,GBK,,GBK是utf8的子集
      -- 校对规则: ci 大小定不敏感,cs或bin大小写敏感
  • D(Delete):删除

    • 删除数据库:

      1
      DROP DATABASE 数据库名称;
    • 删除数据库(判断,如果存在则删除):

      1
      DROP DATABASE IF EXISTS 数据库名称;
  • 使用数据库:

    • 查询当前正在使用的数据库名称

      1
      SELECT DATABASE();
    • 使用数据库

      1
      2
      USE 数据库名称; -- 标准语法
      USE db4; -- 使用db4数据库

数据表

  • R(Retrieve):查询

    • 查询数据库中所有的数据表

      1
      2
      3
      USE mysql;-- 使用mysql数据库

      SHOW TABLES;-- 查询库中所有的表
    • 查询表结构

      1
      DESC 表名;
    • 查询表字符集

      1
      SHOW TABLE STATUS FROM 库名 LIKE '表名';
  • C(Create):创建

    • 创建数据表

      1
      2
      3
      4
      5
      6
      7
      CREATE TABLE 表名(
      列名1 数据类型1,
      列名2 数据类型2,
      ....
      列名n 数据类型n
      );
      -- 注意:最后一列,不需要加逗号
    • 复制表

      1
      2
      3
      CREATE TABLE 表名 LIKE 被复制的表名;  -- 标准语法

      CREATE TABLE product2 LIKE product; -- 复制product表到product2表
    • 数据类型

      数据类型 说明
      INT 整数类型
      DOUBLE 小数类型
      DATE 日期,只包含年月日:yyyy-MM-dd
      DATETIME 日期,包含年月日时分秒:yyyy-MM-dd HH:mm:ss
      TIMESTAMP 时间戳类型,包含年月日时分秒:yyyy-MM-dd HH:mm:ss
      如果不给这个字段赋值或赋值为 NULL,则默认使用当前的系统时间
      CHAR 字符串,定长类型
      VARCHAR 字符串,变长类型
      name varchar(20) 代表姓名最大 20 个字符:zhangsan 8 个字符,张三 2 个字符

      INT(n):n 代表位数

      • 3:int(9)显示结果为 000000010
      • 3:int(3)显示结果为 010

      varchar(n):n 表示的是字符数

    • 例如:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      -- 使用db3数据库
      USE db3;

      -- 创建一个product商品表
      CREATE TABLE product(
      id INT, -- 商品编号
      NAME VARCHAR(30), -- 商品名称
      price DOUBLE, -- 商品价格
      stock INT, -- 商品库存
      insert_time DATE -- 上架时间
      );

  • U(Update):修改

    • 修改表名

      1
      ALTER TABLE 表名 RENAME TO 新的表名;
    • 修改表的字符集

      1
      ALTER TABLE 表名 CHARACTER SET 字符集名称;
    • 添加一列

      1
      ALTER TABLE 表名 ADD 列名 数据类型;
    • 修改列数据类型

      1
      ALTER TABLE 表名 MODIFY 列名 新数据类型;
    • 修改列名称和数据类型

      1
      ALTER TABLE 表名 CHANGE 列名 新列名 新数据类型;
    • 删除列

      1
      ALTER TABLE 表名 DROP 列名;
  • D(Delete):删除

    • 删除数据表

      1
      DROP TABLE 表名;
    • 删除数据表(判断,如果存在则删除)

      1
      DROP TABLE IF EXISTS 表名;

DML

INSERT

  • 新增表数据

    • 新增格式 1:给指定列添加数据

      1
      INSERT INTO 表名(列名1,列名2...) VALUES (值1,值2...);
    • 新增格式 2:默认给全部列添加数据

      1
      INSERT INTO 表名 VALUES (值1,值2,值3,...);
    • 新增格式 3:批量添加数据

      1
      2
      3
      4
      5
      -- 给指定列批量添加数据
      INSERT INTO 表名(列名1,列名2,...) VALUES (值1,值2,...),(值1,值2,...)...;

      -- 默认给所有列批量添加数据
      INSERT INTO 表名 VALUES (值1,值2,值3,...),(值1,值2,值3,...)...;
  • 字符串拼接

    1
    CONCAT(string1,string2,'',...)
  • 注意事项

    • 列名和值的数量以及数据类型要对应
    • 除了数字类型,其他数据类型的数据都需要加引号(单引双引都可以,推荐单引)

UPDATE

  • 修改表数据语法

    • 标准语法

      1
      UPDATE 表名 SET 列名1 = 值1,列名2 = 值2,... [where 条件];
    • 修改电视的价格为1800、库存为36

      1
      2
      UPDATE product SET price=1800,stock=36 WHERE NAME='电视';
      SELECT * FROM product;-- 查看所有商品信息
  • 注意事项

    • 修改语句中必须加条件
    • 如果不加条件,则将所有数据都修改

DELETE

  • 删除表数据语法

    1
    DELETE FROM 表名 [WHERE 条件];
  • 注意事项

    • 删除语句中必须加条件
    • 如果不加条件,则将所有数据删除


DQL

查询语法

数据库查询遵循条件在前的原则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
SELECT DISTINCT
<select list>
FROM
<left_table> <join_type>
JOIN
<right_table> ON <join_condition> -- 连接查询在多表查询部分详解
WHERE
<where_condition>
GROUP BY
<group_by_list>
HAVING
<having_condition>
ORDER BY
<order_by_condition>
LIMIT
<limit_params>

执行顺序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
FROM	<left_table>

ON <join_condition>

<join_type> JOIN <right_table>

WHERE <where_condition>

GROUP BY <group_by_list>

HAVING <having_condition>

SELECT DISTINCT <select list>

ORDER BY <order_by_condition>

LIMIT <limit_params>

查询全部

  • 查询全部的表数据

    1
    2
    3
    4
    5
    -- 标准语法
    SELECT * FROM 表名;

    -- 查询product表所有数据(常用)
    SELECT * FROM product;
  • 查询指定字段的表数据

    1
    SELECT 列名1,列名2,... FROM 表名;
  • 去除重复查询:只有值全部重复的才可以去除,需要创建临时表辅助查询

    1
    SELECT DISTINCT 列名1,列名2,... FROM 表名;
  • 计算列的值(四则运算)

    1
    2
    3
    4
    5
    6
    SELECT 列名1 运算符(+ - * /) 列名2 FROM 表名;

    /*如果某一列值为null,可以进行替换
    ifnull(表达式1,表达式2)
    表达式1:想替换的列
    表达式2:想替换的值*/

    例如:

    1
    2
    3
    4
    5
    -- 查询商品名称和库存,库存数量在原有基础上加10
    SELECT NAME,stock+10 FROM product;

    -- 查询商品名称和库存,库存数量在原有基础上加10。进行null值判断
    SELECT NAME,IFNULL(stock,0)+10 FROM product;
  • 起别名

    1
    SELECT 列名1,列名2,... AS 别名 FROM 表名;

    例如:

    1
    2
    3
    -- 查询商品名称和库存,库存数量在原有基础上加10。进行null值判断,起别名为getSum,AS可以省略。
    SELECT NAME,IFNULL(stock,0)+10 AS getsum FROM product;
    SELECT NAME,IFNULL(stock,0)+10 getsum FROM product;

条件查询

  • 条件查询语法

    1
    SELECT 列名 FROM 表名 WHERE 条件;
  • 条件分类

    符号 功能
    > 大于
    < 小于
    >= 大于等于
    <= 小于等于
    = 等于
    <> 或 != 不等于
    BETWEEN … AND … 在某个范围之内(都包含)
    IN(…) 多选一
    LIKE 模糊查询:_单个任意字符、%任意个字符、[] 匹配集合内的字符
    LIKE '[^AB]%' :不以 A 和 B 开头的任意文本
    IS NULL 是NULL
    IS NOT NULL 不是NULL
    AND 或 && 并且
    OR 或 || 或者
    NOT 或 ! 非,不是
    UNION 对两个结果集进行并集操作并进行去重,同时进行默认规则的排序
    UNION ALL 对两个结果集进行并集操作不进行去重,不进行排序
  • 例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    -- 查询库存大于20的商品信息
    SELECT * FROM product WHERE stock > 20;

    -- 查询品牌为华为的商品信息
    SELECT * FROM product WHERE brand='华为';

    -- 查询金额在4000 ~ 6000之间的商品信息
    SELECT * FROM product WHERE price >= 4000 AND price <= 6000;
    SELECT * FROM product WHERE price BETWEEN 4000 AND 6000;

    -- 查询库存为14、30、23的商品信息
    SELECT * FROM product WHERE stock=14 OR stock=30 OR stock=23;
    SELECT * FROM product WHERE stock IN(14,30,23);

    -- 查询库存为null的商品信息
    SELECT * FROM product WHERE stock IS NULL;
    -- 查询库存不为null的商品信息
    SELECT * FROM product WHERE stock IS NOT NULL;

    -- 查询名称以'小米'为开头的商品信息
    SELECT * FROM product WHERE NAME LIKE '小米%';

    -- 查询名称第二个字是'为'的商品信息
    SELECT * FROM product WHERE NAME LIKE '_为%';

    -- 查询名称为四个字符的商品信息 4个下划线
    SELECT * FROM product WHERE NAME LIKE '____';

    -- 查询名称中包含电脑的商品信息
    SELECT * FROM product WHERE NAME LIKE '%电脑%';

函数查询

聚合函数

聚合函数:将一列数据作为一个整体,进行纵向的计算

  • 聚合函数语法

    1
    SELECT 函数名(列名) FROM 表名 [WHERE 条件]
  • 聚合函数分类

    函数名 功能
    COUNT(列名) 统计数量(一般选用不为 null 的列)
    MAX(列名) 最大值
    MIN(列名) 最小值
    SUM(列名) 求和
    AVG(列名) 平均值(会忽略 null 行)
  • 例如

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    -- 计算product表中总记录条数 7
    SELECT COUNT(*) FROM product;

    -- 获取最高价格
    SELECT MAX(price) FROM product;
    -- 获取最高价格的商品名称
    SELECT NAME,price FROM product WHERE price = (SELECT MAX(price) FROM product);

    -- 获取最低库存
    SELECT MIN(stock) FROM product;
    -- 获取最低库存的商品名称
    SELECT NAME,stock FROM product WHERE stock = (SELECT MIN(stock) FROM product);

    -- 获取总库存数量
    SELECT SUM(stock) FROM product;
    -- 获取品牌为小米的平均商品价格
    SELECT AVG(price) FROM product WHERE brand='小米';

文本函数

CONCAT():用于连接两个字段

1
2
SELECT CONCAT(TRIM(col1), '(', TRIM(col2), ')') AS concat_col FROM mytable
-- 许多数据库会使用空格把一个值填充为列宽,连接的结果出现一些不必要的空格,使用TRIM()可以去除首尾空格
函数名称 作 用
LENGTH 计算字符串长度函数,返回字符串的字节长度
CONCAT 合并字符串函数,返回结果为连接参数产生的字符串,参数可以使一个或多个
INSERT 替换字符串函数
LOWER 将字符串中的字母转换为小写
UPPER 将字符串中的字母转换为大写
LEFT 从左侧字截取符串,返回字符串左边的若干个字符
RIGHT 从右侧字截取符串,返回字符串右边的若干个字符
TRIM 删除字符串左右两侧的空格
REPLACE 字符串替换函数,返回替换后的新字符串
SUBSTRING 截取字符串,返回从指定位置开始的指定长度的字符换
REVERSE 字符串反转(逆序)函数,返回与原始字符串顺序相反的字符串

数字函数
函数名称 作 用
ABS 求绝对值
SQRT 求二次方根
MOD 求余数
CEIL 和 CEILING 两个函数功能相同,都是返回不小于参数的最小整数,即向上取整
FLOOR 向下取整,返回值转化为一个BIGINT
RAND 生成一个0~1之间的随机数,传入整数参数是,用来产生重复序列
ROUND 对所传参数进行四舍五入
SIGN 返回参数的符号
POW 和 POWER 两个函数的功能相同,都是所传参数的次方的结果值
SIN 求正弦值
ASIN 求反正弦值,与函数 SIN 互为反函数
COS 求余弦值
ACOS 求反余弦值,与函数 COS 互为反函数
TAN 求正切值
ATAN 求反正切值,与函数 TAN 互为反函数
COT 求余切值

日期函数
函数名称 作 用
CURDATE 和 CURRENT_DATE 两个函数作用相同,返回当前系统的日期值
CURTIME 和 CURRENT_TIME 两个函数作用相同,返回当前系统的时间值
NOW 和 SYSDATE 两个函数作用相同,返回当前系统的日期和时间值
MONTH 获取指定日期中的月份
MONTHNAME 获取指定日期中的月份英文名称
DAYNAME 获取指定曰期对应的星期几的英文名称
DAYOFWEEK 获取指定日期对应的一周的索引位置值
WEEK 获取指定日期是一年中的第几周,返回值的范围是否为 0〜52 或 1〜53
DAYOFYEAR 获取指定曰期是一年中的第几天,返回值范围是1~366
DAYOFMONTH 获取指定日期是一个月中是第几天,返回值范围是1~31
YEAR 获取年份,返回值范围是 1970〜2069
TIME_TO_SEC 将时间参数转换为秒数
SEC_TO_TIME 将秒数转换为时间,与TIME_TO_SEC 互为反函数
DATE_ADD 和 ADDDATE 两个函数功能相同,都是向日期添加指定的时间间隔
DATE_SUB 和 SUBDATE 两个函数功能相同,都是向日期减去指定的时间间隔
ADDTIME 时间加法运算,在原始时间上添加指定的时间
SUBTIME 时间减法运算,在原始时间上减去指定的时间
DATEDIFF 获取两个日期之间间隔,返回参数 1 减去参数 2 的值
DATE_FORMAT 格式化指定的日期,根据参数返回指定格式的值
WEEKDAY 获取指定日期在一周内的对应的工作日索引

正则查询

正则表达式(Regular Expression)是指一个用来描述或者匹配一系列符合某个句法规则的字符串的单个字符串

1
2
3
SELECT * FROM emp WHERE name REGEXP '^T';	-- 匹配以T开头的name值
SELECT * FROM emp WHERE name REGEXP '2$'; -- 匹配以2结尾的name值
SELECT * FROM emp WHERE name REGEXP '[uvw]';-- 匹配包含 uvw 的name值
符号 含义
^ 在字符串开始处进行匹配
$ 在字符串末尾处进行匹配
. 匹配任意单个字符, 包括换行符
[…] 匹配出括号内的任意字符
[^…] 匹配不出括号内的任意字符
a* 匹配零个或者多个a(包括空串)
a+ 匹配一个或者多个a(不包括空串)
a? 匹配零个或者一个a
a1|a2 匹配a1或a2
a(m) 匹配m个a
a(m,) 至少匹配m个a
a(m,n) 匹配m个a 到 n个a
a(,n) 匹配0到n个a
(…) 将模式元素组成单一元素

排序查询

  • 排序查询语法

    1
    SELECT 列名 FROM 表名 [WHERE 条件] ORDER BY 列名1 排序方式1,列名2 排序方式2;
  • 排序方式

    1
    2
    ASC:升序
    DESC:降序

    注意:多个排序条件,当前边的条件值一样时,才会判断第二条件

  • 例如

    1
    2
    3
    4
    5
    6
    7
    8
    -- 按照库存升序排序
    SELECT * FROM product ORDER BY stock ASC;

    -- 查询名称中包含手机的商品信息。按照金额降序排序
    SELECT * FROM product WHERE NAME LIKE '%手机%' ORDER BY price DESC;

    -- 按照金额升序排序,如果金额相同,按照库存降序排列
    SELECT * FROM product ORDER BY price ASC,stock DESC;

分组查询

分组查询会进行去重

  • 分组查询语法

    1
    SELECT 列名 FROM 表名 [WHERE 条件] GROUP BY 分组列名 [HAVING 分组后条件过滤] [ORDER BY 排序列名 排序方式];

    WHERE 过滤行,HAVING 过滤分组,行过滤应当先于分组过滤

    分组规定:

    • GROUP BY 子句出现在 WHERE 子句之后,ORDER BY 子句之前
    • NULL 的行会单独分为一组
    • 大多数 SQL 实现不支持 GROUP BY 列具有可变长度的数据类型
  • 例如

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    -- 按照品牌分组,获取每组商品的总金额
    SELECT brand,SUM(price) FROM product GROUP BY brand;

    -- 对金额大于4000元的商品,按照品牌分组,获取每组商品的总金额
    SELECT brand,SUM(price) FROM product WHERE price > 4000 GROUP BY brand;

    -- 对金额大于4000元的商品,按照品牌分组,获取每组商品的总金额,只显示总金额大于7000元的
    SELECT brand,SUM(price) AS getSum FROM product WHERE price > 4000 GROUP BY brand HAVING getSum > 7000;

    -- 对金额大于4000元的商品,按照品牌分组,获取每组商品的总金额,只显示总金额大于7000元的、并按照总金额的降序排列
    SELECT brand,SUM(price) AS getSum FROM product WHERE price > 4000 GROUP BY brand HAVING getSum > 7000 ORDER BY getSum DESC;

分页查询

  • 分页查询语法

    1
    SELECT 列名 FROM 表名 [WHERE 条件] GROUP BY 分组列名 [HAVING 分组后条件过滤] [ORDER BY 排序列名 排序方式] LIMIT 开始索引,查询条数;
  • 公式:开始索引 = (当前页码-1) * 每页显示的条数

  • 例如

    1
    2
    3
    4
    SELECT * FROM product LIMIT 0,2;  -- 第一页 开始索引=(1-1) * 2
    SELECT * FROM product LIMIT 2,2; -- 第二页 开始索引=(2-1) * 2
    SELECT * FROM product LIMIT 4,2; -- 第三页 开始索引=(3-1) * 2
    SELECT * FROM product LIMIT 6,2; -- 第四页 开始索引=(4-1) * 2


多表操作

约束分类

约束介绍

约束:对表中的数据进行限定,保证数据的正确性、有效性、完整性

约束的分类:

约束 说明
PRIMARY KEY 主键约束
PRIMARY KEY AUTO_INCREMENT 主键、自动增长
UNIQUE 唯一约束
NOT NULL 非空约束
FOREIGN KEY 外键约束
FOREIGN KEY ON UPDATE CASCADE 外键级联更新
FOREIGN KEY ON DELETE CASCADE 外键级联删除

主键约束

  • 主键约束特点:

    • 主键约束默认包含非空和唯一两个功能
    • 一张表只能有一个主键
    • 主键一般用于表中数据的唯一标识
  • 建表时添加主键约束

    1
    2
    3
    4
    5
    CREATE TABLE 表名(
    列名 数据类型 PRIMARY KEY,
    列名 数据类型,
    ...
    );
  • 删除主键约束

    1
    ALTER TABLE 表名 DROP PRIMARY KEY;
  • 建表后单独添加主键约束

    1
    ALTER TABLE 表名 MODIFY 列名 数据类型 PRIMARY KEY;
  • 例如

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    -- 创建student表
    CREATE TABLE student(
    id INT PRIMARY KEY -- 给id添加主键约束
    );

    -- 添加数据
    INSERT INTO student VALUES (1),(2);
    -- 主键默认唯一,添加重复数据,会报错
    INSERT INTO student VALUES (2);
    -- 主键默认非空,不能添加null的数据
    INSERT INTO student VALUES (NULL);

主键自增

主键自增约束可以为空,并自动增长。删除某条数据不影响自增的下一个数值,依然按照前一个值自增

  • 建表时添加主键自增约束

    1
    2
    3
    4
    5
    CREATE TABLE 表名(
    列名 数据类型 PRIMARY KEY AUTO_INCREMENT,
    列名 数据类型,
    ...
    );
  • 删除主键自增约束

    1
    ALTER TABLE 表名 MODIFY 列名 数据类型;
  • 建表后单独添加主键自增约束

    1
    ALTER TABLE 表名 MODIFY 列名 数据类型 AUTO_INCREMENT;
  • 例如

    1
    2
    3
    4
    5
    6
    7
    8
    9
    -- 创建student2表
    CREATE TABLE student2(
    id INT PRIMARY KEY AUTO_INCREMENT -- 给id添加主键自增约束
    );

    -- 添加数据
    INSERT INTO student2 VALUES (1),(2);
    -- 添加null值,会自动增长
    INSERT INTO student2 VALUES (NULL),(NULL);-- 3,4

唯一约束

唯一约束:约束不能有重复的数据

  • 建表时添加唯一约束

    1
    2
    3
    4
    5
    CREATE TABLE 表名(
    列名 数据类型 UNIQUE,
    列名 数据类型,
    ...
    );
  • 删除唯一约束

    1
    ALTER TABLE 表名 DROP INDEX 列名;
  • 建表后单独添加唯一约束

    1
    ALTER TABLE 表名 MODIFY 列名 数据类型 UNIQUE;

非空约束

  • 建表时添加非空约束

    1
    2
    3
    4
    5
    CREATE TABLE 表名(
    列名 数据类型 NOT NULL,
    列名 数据类型,
    ...
    );
  • 删除非空约束

    1
    ALTER TABLE 表名 MODIFY 列名 数据类型;
  • 建表后单独添加非空约束

    1
    ALTER TABLE 表名 MODIFY 列名 数据类型 NOT NULL;

外键约束

外键约束:让表和表之间产生关系,从而保证数据的准确性

  • 建表时添加外键约束

    1
    2
    3
    4
    5
    CREATE TABLE 表名(
    列名 数据类型 约束,
    ...
    CONSTRAINT 外键名 FOREIGN KEY (本表外键列名) REFERENCES 主表名(主表主键列名)
    );
  • 删除外键约束

    1
    ALTER TABLE 表名 DROP FOREIGN KEY 外键名;
  • 建表后单独添加外键约束

    1
    ALTER TABLE 表名 ADD CONSTRAINT 外键名 FOREIGN KEY (本表外键列名) REFERENCES 主表名(主表主键列名);
  • 例如

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    -- 创建user用户表
    CREATE TABLE USER(
    id INT PRIMARY KEY AUTO_INCREMENT, -- id
    name VARCHAR(20) NOT NULL -- 姓名
    );
    -- 添加用户数据
    INSERT INTO USER VALUES (NULL,'张三'),(NULL,'李四'),(NULL,'王五');

    -- 创建orderlist订单表
    CREATE TABLE orderlist(
    id INT PRIMARY KEY AUTO_INCREMENT, -- id
    number VARCHAR(20) NOT NULL, -- 订单编号
    uid INT, -- 订单所属用户
    CONSTRAINT ou_fk1 FOREIGN KEY (uid) REFERENCES USER(id) -- 添加外键约束
    );
    -- 添加订单数据
    INSERT INTO orderlist VALUES (NULL,'hm001',1),(NULL,'hm002',1),
    (NULL,'hm003',2),(NULL,'hm004',2),
    (NULL,'hm005',3),(NULL,'hm006',3);

    -- 添加一个订单,但是没有所属用户。无法添加
    INSERT INTO orderlist VALUES (NULL,'hm007',8);
    -- 删除王五这个用户,但是订单表中王五还有很多个订单呢。无法删除
    DELETE FROM USER WHERE NAME='王五';

外键级联

级联操作:当把主表中的数据进行删除或更新时,从表中有关联的数据的相应操作,包括 RESTRICT、CASCADE、SET NULL 和 NO ACTION

  • RESTRICT 和 NO ACTION相同, 是指限制在子表有关联记录的情况下, 父表不能更新

  • CASCADE 表示父表在更新或者删除时,更新或者删除子表对应的记录

  • SET NULL 则表示父表在更新或者删除的时候,子表的对应字段被SET NULL

级联操作:

  • 添加级联更新

    1
    ALTER TABLE 表名 ADD CONSTRAINT 外键名 FOREIGN KEY (本表外键列名) REFERENCES 主表名(主表主键列名) ON UPDATE [CASCADE | RESTRICT | SET NULL];
  • 添加级联删除

    1
    ALTER TABLE 表名 ADD CONSTRAINT 外键名 FOREIGN KEY (本表外键列名) REFERENCES 主表名(主表主键列名) ON DELETE CASCADE;
  • 同时添加级联更新和级联删除

    1
    ALTER TABLE 表名 ADD CONSTRAINT 外键名 FOREIGN KEY (本表外键列名) REFERENCES 主表名(主表主键列名) ON UPDATE CASCADE ON DELETE CASCADE;

多表设计

一对一

多表:有多张数据表,而表与表之间有一定的关联关系,通过外键约束实现,分为一对一、一对多、多对多三类

举例:人和身份证

实现原则:在任意一个表建立外键,去关联另外一个表的主键

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- 创建person表
CREATE TABLE person(
id INT PRIMARY KEY AUTO_INCREMENT, -- 主键id
NAME VARCHAR(20) -- 姓名
);
-- 添加数据
INSERT INTO person VALUES (NULL,'张三'),(NULL,'李四');

-- 创建card表
CREATE TABLE card(
id INT PRIMARY KEY AUTO_INCREMENT, -- 主键id
number VARCHAR(20) UNIQUE NOT NULL, -- 身份证号
pid INT UNIQUE, -- 外键列
CONSTRAINT cp_fk1 FOREIGN KEY (pid) REFERENCES person(id)
);
-- 添加数据
INSERT INTO card VALUES (NULL,'12345',1),(NULL,'56789',2);


一对多

举例:用户和订单、商品分类和商品

实现原则:在多的一方,建立外键约束,来关联一的一方主键

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- 创建user表
CREATE TABLE USER(
id INT PRIMARY KEY AUTO_INCREMENT, -- 主键id
NAME VARCHAR(20) -- 姓名
);
-- 添加数据
INSERT INTO USER VALUES (NULL,'张三'),(NULL,'李四');

-- 创建orderlist表
CREATE TABLE orderlist(
id INT PRIMARY KEY AUTO_INCREMENT, -- 主键id
number VARCHAR(20), -- 订单编号
uid INT, -- 外键列
CONSTRAINT ou_fk1 FOREIGN KEY (uid) REFERENCES USER(id)
);
-- 添加数据
INSERT INTO orderlist VALUES (NULL,'hm001',1),(NULL,'hm002',1),(NULL,'hm003',2),(NULL,'hm004',2);

多表设计一对多


多对多

举例:学生和课程。一个学生可以选择多个课程,一个课程也可以被多个学生选择

实现原则:借助第三张表中间表,中间表至少包含两个列,这两个列作为中间表的外键,分别关联两张表的主键

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
-- 创建student表
CREATE TABLE student(
id INT PRIMARY KEY AUTO_INCREMENT, -- 主键id
NAME VARCHAR(20) -- 学生姓名
);
-- 添加数据
INSERT INTO student VALUES (NULL,'张三'),(NULL,'李四');

-- 创建course表
CREATE TABLE course(
id INT PRIMARY KEY AUTO_INCREMENT, -- 主键id
NAME VARCHAR(10) -- 课程名称
);
-- 添加数据
INSERT INTO course VALUES (NULL,'语文'),(NULL,'数学');

-- 创建中间表
CREATE TABLE stu_course(
id INT PRIMARY KEY AUTO_INCREMENT, -- 主键id
sid INT, -- 用于和student表中的id进行外键关联
cid INT, -- 用于和course表中的id进行外键关联
CONSTRAINT sc_fk1 FOREIGN KEY (sid) REFERENCES student(id), -- 添加外键约束
CONSTRAINT sc_fk2 FOREIGN KEY (cid) REFERENCES course(id) -- 添加外键约束
);
-- 添加数据
INSERT INTO stu_course VALUES (NULL,1,1),(NULL,1,2),(NULL,2,1),(NULL,2,2);


连接查询

内外连接

内连接

连接查询的是两张表有交集的部分数据,两张表分为驱动表和被驱动表,如果结果集中的每条记录都是两个表相互匹配的组合,则称这样的结果集为笛卡尔积

内连接查询,若驱动表中的记录在被驱动表中找不到匹配的记录时,则该记录不会加到最后的结果集

  • 显式内连接:

    1
    SELECT 列名 FROM 表名1 [INNER] JOIN 表名2 ON 条件;
  • 隐式内连接:内连接中 WHERE 子句和 ON 子句是等价的

    1
    SELECT 列名 FROM 表名1,表名2 WHERE 条件;

STRAIGHT_JOIN与 JOIN 类似,只不过左表始终在右表之前读取,只适用于内连接


外连接

外连接查询,若驱动表中的记录在被驱动表中找不到匹配的记录时,则该记录也会加到最后的结果集,只是对于被驱动表中不匹配过滤条件的记录,各个字段使用 NULL 填充

应用实例:查学生成绩,也想展示出缺考的人的成绩

  • 左外连接:选择左侧的表为驱动表,查询左表的全部数据,和左右两张表有交集部分的数据

    1
    SELECT 列名 FROM 表名1 LEFT [OUTER] JOIN 表名2 ON 条件;
  • 右外连接:选择右侧的表为驱动表,查询右表的全部数据,和左右两张表有交集部分的数据

    1
    SELECT 列名 FROM 表名1 RIGHT [OUTER] JOIN 表名2 ON 条件;


关联查询

自关联查询:同一张表中有数据关联,可以多次查询这同一个表

  • 数据准备

    1
    2
    3
    4
    5
    6
    7
    8
    9
    -- 创建员工表
    CREATE TABLE employee(
    id INT PRIMARY KEY AUTO_INCREMENT, -- 员工编号
    NAME VARCHAR(20), -- 员工姓名
    mgr INT, -- 上级编号
    salary DOUBLE -- 员工工资
    );
    -- 添加数据
    INSERT INTO employee VALUES (1001,'孙悟空',1005,9000.00),..,(1009,'宋江',NULL,16000.00);

  • 数据查询

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    -- 查询所有员工的姓名及其直接上级的姓名,没有上级的员工也需要查询
    /*
    分析
    员工信息 employee表
    条件:employee.mgr = employee.id
    查询左表的全部数据,和左右两张表有交集部分数据,左外连接
    */
    SELECT
    e1.id,
    e1.name,
    e1.mgr,
    e2.id,
    e2.name
    FROM
    employee e1
    LEFT OUTER JOIN
    employee e2
    ON
    e1.mgr = e2.id;
  • 查询结果

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    id		name	mgr	   id	  name
    1001 孙悟空 1005 1005 唐僧
    1002 猪八戒 1005 1005 唐僧
    1003 沙和尚 1005 1005 唐僧
    1004 小白龙 1005 1005 唐僧
    1005 唐僧 NULL NULL NULL
    1006 武松 1009 1009 宋江
    1007 李逵 1009 1009 宋江
    1008 林冲 1009 1009 宋江
    1009 宋江 NULL NULL NULL

连接原理

Index Nested-Loop Join 算法:查询驱动表得到数据集,然后根据数据集中的每一条记录的关联字段再分别到被驱动表中查找匹配(走索引),所以驱动表只需要访问一次,被驱动表要访问多次

MySQL 将查询驱动表后得到的记录成为驱动表的扇出,连接查询的成本:单次访问驱动表的成本 + 扇出值 * 单次访问被驱动表的成本,优化器会选择成本最小的表连接顺序(确定谁是驱动表,谁是被驱动表)生成执行计划,进行连接查询,优化方式:

  • 减少驱动表的扇出(让数据量小的表来做驱动表)
  • 降低访问被驱动表的成本

说明:STRAIGHT_JOIN 是查一条驱动表,然后根据关联字段去查被驱动表,要访问多次驱动表,所以需要优化为 INL 算法

Block Nested-Loop Join 算法:一种空间换时间的优化方式,基于块的循环连接,执行连接查询前申请一块固定大小的内存作为连接缓冲区 Join Buffer,先把若干条驱动表中的扇出暂存在缓冲区,每一条被驱动表中的记录一次性的与 Buffer 中多条记录进行匹配(扫描全部数据,一条一条的匹配),因为是在内存中完成,所以速度快,并且降低了 I/O 成本

Join Buffer 可以通过参数 join_buffer_size 进行配置,默认大小是 256 KB

在成本分析时,对于很多张表的连接查询,连接顺序有非常多,MySQL 如果挨着进行遍历计算成本,会消耗很多资源

  • 提前结束某种连接顺序的成本评估:维护一个全局变量记录当前成本最小的连接方式,如果一种顺序只计算了一部分就已经超过了最小成本,可以提前结束计算

  • 系统变量 optimizer_search_depth:如果连接表的个数小于该变量,就继续穷举分析每一种连接数量,反之只对数量与 depth 值相同的表进行分析,该值越大成本分析的越精确

  • 系统变量 optimizer_prune_level:控制启发式规则的启用,这些规则就是根据以往经验指定的,不满足规则的连接顺序不分析成本


连接优化

BKA

Batched Key Access 算法是对 NLJ 算法的优化,在读取被驱动表的记录时使用顺序 IO,Extra 信息中会有 Batched Key Access 信息

使用 BKA 的表的 JOIN 过程如下:

  • 连接驱动表将满足条件的记录放入 Join Buffer,并将两表连接的字段放入一个 DYNAMIC_ARRAY ranges 中
  • 在进行表的过接过程中,会将 ranges 相关的信息传入 Buffer 中,进行被驱动表主建的查找及排序操作
  • 调用步骤 2 中产生的有序主建,顺序读取被驱动表的数据
  • 当缓冲区的数据被读完后,会重复进行步骤 2、3,直到记录被读取完

使用 BKA 优化需要设进行设置:

1
SET optimizer_switch='mrr=on,mrr_cost_based=off,batched_key_access=on';

说明:前两个参数的作用是启用 MRR,因为 BKA 算法的优化要依赖于 MRR(系统优化 → 内存优化 → Read 详解)


BNL
问题

BNL 即 Block Nested-Loop Join 算法,由于要访问多次被驱动表,会产生两个问题:

  • Join 语句多次扫描一个冷表,并且语句执行时间小于 1 秒,就会在再次扫描冷表时,把冷表的数据页移到 LRU 链表头部,导致热数据被淘汰,影响业务的正常运行

    这种情况冷表的数据量要小于整个 Buffer Pool 的 old 区域,能够完全放入 old 区,才会再次被读时加到 young,否则读取下一段时就已经把上一段淘汰

  • Join 语句在循环读磁盘和淘汰内存页,进入 old 区域的数据页很可能在 1 秒之内就被淘汰,就会导致 MySQL 实例的 Buffer Pool 在这段时间内 young 区域的数据页没有被合理地淘汰

大表 Join 操作虽然对 IO 有影响,但是在语句执行结束后对 IO 的影响随之结束。但是对 Buffer Pool 的影响就是持续性的,需要依靠后续的查询请求慢慢恢复内存命中率

优化

将 BNL 算法转成 BKA 算法,优化方向:

  • 在被驱动表上建索引,这样就可以根据索引进行顺序 IO
  • 使用临时表,在临时表上建立索引,将被驱动表和临时表进行连接查询

驱动表 t1,被驱动表 t2,使用临时表的工作流程:

  • 把表 t1 中满足条件的数据放在临时表 tmp_t 中
  • 给临时表 tmp_t 的关联字段加上索引,使用 BKA 算法
  • 让表 t2 和 tmp_t 做 Join 操作(临时表是被驱动表)

补充:MySQL 8.0 支持 hash join,join_buffer 维护的不再是一个无序数组,而是一个哈希表,查询效率更高,执行效率比临时表更高


嵌套查询

查询分类

查询语句中嵌套了查询语句,将嵌套查询称为子查询,FROM 子句后面的子查询的结果集称为派生表

根据结果分类:

  • 结果是单行单列:可以将查询的结果作为另一条语句的查询条件,使用运算符判断

    1
    SELECT 列名 FROM 表名 WHERE 列名=(SELECT 列名/聚合函数(列名) FROM 表名 [WHERE 条件]);
  • 结果是多行单列:可以作为条件,使用运算符 IN 或 NOT IN 进行判断

    1
    SELECT 列名 FROM 表名 WHERE 列名 [NOT] IN (SELECT 列名 FROM 表名 [WHERE 条件]); 
  • 结果是多行多列:查询的结果可以作为一张虚拟表参与查询

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    SELECT 列名 FROM 表名 [别名],(SELECT 列名 FROM 表名 [WHERE 条件]) [别名] [WHERE 条件];

    -- 查询订单表orderlist中id大于4的订单信息和所属用户USER信息
    SELECT
    *
    FROM
    USER u,
    (SELECT * FROM orderlist WHERE id>4) o
    WHERE
    u.id=o.uid;

相关性分类:

  • 不相关子查询:子查询不依赖外层查询的值,可以单独运行出结果
  • 相关子查询:子查询的执行需要依赖外层查询的值

查询优化

不相关子查询的结果集会被写入一个临时表,并且在写入时去重,该过程称为物化,存储结果集的临时表称为物化表

系统变量 tmp_table_size 或者 max_heap_table_size 为表的最值

  • 小于系统变量时,内存中可以保存,会为建立基于内存的 MEMORY 存储引擎的临时表,并建立哈希索引
  • 大于任意一个系统变量时,物化表会使用基于磁盘的 InnoDB 存储引擎来保存结果集中的记录,索引类型为 B+ 树

物化后,嵌套查询就相当于外层查询的表和物化表进行内连接查询,然后经过优化器选择成本最小的表连接顺序执行查询

子查询物化会产生建立临时表的成本,但是将子查询转化为连接查询可以充分发挥优化器的作用,所以引入:半连接

  • t1 和 t2 表进行半连接,对于 t1 表中的某条记录,只需要关心在 t2 表中是否存在,而不需要关心有多少条记录与之匹配,最终结果集只保留 t1 的记录
  • 半连接只是执行子查询的一种方式,MySQL 并没有提供面向用户的半连接语法

参考书籍:https://book.douban.com/subject/35231266/


联合查询

UNION 是取这两个子查询结果的并集,并进行去重,同时进行默认规则的排序(union 是行加起来,join 是列加起来)

UNION ALL 是对两个结果集进行并集操作不进行去重,不进行排序

1
(select 1000 as f) union (select id from t1 order by id desc limit 2); #t1表中包含id 为 1-1000 的数据

语句的执行流程:

  • 创建一个内存临时表,这个临时表只有一个整型字段 f,并且 f 是主键字段
  • 执行第一个子查询,得到 1000 这个值,并存入临时表中
  • 执行第二个子查询,拿到第一行 id=1000,试图插入临时表中,但由于 1000 这个值已经存在于临时表了,违反了唯一性约束,所以插入失败,然后继续执行
  • 取到第二行 id=999,插入临时表成功
  • 从临时表中按行取出数据,返回结果并删除临时表,结果中包含两行数据分别是 1000 和 999

查询练习

数据准备:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
-- 创建db4数据库
CREATE DATABASE db4;
-- 使用db4数据库
USE db4;

-- 创建user表
CREATE TABLE USER(
id INT PRIMARY KEY AUTO_INCREMENT, -- 用户id
NAME VARCHAR(20), -- 用户姓名
age INT -- 用户年龄
);

-- 订单表
CREATE TABLE orderlist(
id INT PRIMARY KEY AUTO_INCREMENT, -- 订单id
number VARCHAR(30), -- 订单编号
uid INT, -- 外键字段
CONSTRAINT ou_fk1 FOREIGN KEY (uid) REFERENCES USER(id)
);

-- 商品分类表
CREATE TABLE category(
id INT PRIMARY KEY AUTO_INCREMENT, -- 商品分类id
NAME VARCHAR(10) -- 商品分类名称
);

-- 商品表
CREATE TABLE product(
id INT PRIMARY KEY AUTO_INCREMENT, -- 商品id
NAME VARCHAR(30), -- 商品名称
cid INT, -- 外键字段
CONSTRAINT cp_fk1 FOREIGN KEY (cid) REFERENCES category(id)
);

-- 中间表
CREATE TABLE us_pro(
upid INT PRIMARY KEY AUTO_INCREMENT, -- 中间表id
uid INT, -- 外键字段。需要和用户表的主键产生关联
pid INT, -- 外键字段。需要和商品表的主键产生关联
CONSTRAINT up_fk1 FOREIGN KEY (uid) REFERENCES USER(id),
CONSTRAINT up_fk2 FOREIGN KEY (pid) REFERENCES product(id)
);

多表练习架构设计

数据查询:

  1. 查询用户的编号、姓名、年龄、订单编号

    数据:用户的编号、姓名、年龄在 user 表,订单编号在 orderlist 表

    条件:user.id = orderlist.uid

    1
    2
    3
    4
    5
    6
    7
    8
    SELECT
    u.*,
    o.number
    FROM
    USER u,
    orderlist o
    WHERE
    u.id = o.uid;
  2. 查询所有的用户,显示用户的编号、姓名、年龄、订单编号。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    SELECT
    u.*,
    o.number
    FROM
    USER u
    LEFT OUTER JOIN
    orderlist o
    ON
    u.id = o.uid;
  3. 查询用户年龄大于 23 岁的信息,显示用户的编号、姓名、年龄、订单编号

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    SELECT
    u.*,
    o.number
    FROM
    USER u,
    orderlist o
    WHERE
    u.id = o.uid
    AND
    u.age > 23;
    1
    2
    3
    4
    5
    6
    7
    8
    SELECT
    u.*,
    o.number
    FROM
    (SELECT * FROM USER WHERE age > 23) u,-- 嵌套查询
    orderlist o
    WHERE
    u.id = o.uid;
  4. 查询张三和李四用户的信息,显示用户的编号、姓名、年龄、订单编号。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    SELECT
    u.*,
    o.number
    FROM
    USER u,
    orderlist o
    WHERE
    u.id=o.uid
    AND
    u.name IN ('张三','李四');
  5. 查询所有的用户和该用户能查看的所有的商品,显示用户的编号、姓名、年龄、商品名称

    数据:用户的编号、姓名、年龄在 user 表,商品名称在 product 表,中间表 us_pro

    条件:us_pro.uid = user.id AND us_pro.pid = product.id

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    SELECT
    u.id,
    u.name,
    u.age,
    p.name
    FROM
    USER u,
    product p,
    us_pro up
    WHERE
    up.uid = u.id
    AND
    up.pid=p.id;
  6. 查询张三和李四这两个用户可以看到的商品,显示用户的编号、姓名、年龄、商品名称。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    SELECT
    u.id,
    u.name,
    u.age,
    p.name
    FROM
    USER u,
    product p,
    us_pro up
    WHERE
    up.uid=u.id
    AND
    up.pid=p.id
    AND
    u.name IN ('张三','李四');

高级结构

视图

基本介绍

视图概念:视图是一种虚拟存在的数据表,这个虚拟的表并不在数据库中实际存在

本质:将一条 SELECT 查询语句的结果封装到了一个虚拟表中,所以在创建视图的时候,工作重心要放在这条 SELECT 查询语句上

作用:将一些比较复杂的查询语句的结果,封装到一个虚拟表中,再有相同查询需求时,直接查询该虚拟表

优点:

  • 简单:使用视图的用户不需要关心表的结构、关联条件和筛选条件,因为虚拟表中已经是过滤好的结果集

  • 安全:使用视图的用户只能访问查询的结果集,对表的权限管理并不能限制到某个行某个列

  • 数据独立,一旦视图的结构确定,可以屏蔽表结构变化对用户的影响,源表增加列对视图没有影响;源表修改列名,则可以通过修改视图来解决,不会造成对访问者的影响


视图创建

  • 创建视图

    1
    2
    3
    4
    CREATE [OR REPLACE] 
    VIEW 视图名称 [(列名列表)]
    AS 查询语句
    [WITH [CASCADED | LOCAL] CHECK OPTION];

    WITH [CASCADED | LOCAL] CHECK OPTION 决定了是否允许更新数据使记录不再满足视图的条件:

    • LOCAL:只要满足本视图的条件就可以更新
    • CASCADED:必须满足所有针对该视图的所有视图的条件才可以更新, 默认值
  • 例如

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    -- 数据准备 city
    id NAME cid
    1 深圳 1
    2 上海 1
    3 纽约 2
    4 莫斯科 3

    -- 数据准备 country
    id NAME
    1 中国
    2 美国
    3 俄罗斯

    -- 创建city_country视图,保存城市和国家的信息(使用指定列名)
    CREATE
    VIEW
    city_country (city_id,city_name,country_name)
    AS
    SELECT
    c1.id,
    c1.name,
    c2.name
    FROM
    city c1,
    country c2
    WHERE
    c1.cid=c2.id;

视图查询

  • 查询所有数据表,视图也会查询出来

    1
    2
    SHOW TABLES;
    SHOW TABLE STATUS [\G];
  • 查询视图

    1
    SELECT * FROM 视图名称;
  • 查询某个视图创建

    1
    SHOW CREATE VIEW 视图名称;

视图修改

视图表数据修改,会自动修改源表中的数据,因为更新的是视图中的基表中的数据

  • 修改视图表中的数据

    1
    UPDATE 视图名称 SET 列名 = 值 WHERE 条件;
  • 修改视图的结构

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    ALTER [ALGORITHM = {UNDEFINED | MERGE | TEMPTABLE}]
    VIEW 视图名称 [(列名列表)]
    AS 查询语句
    [WITH [CASCADED | LOCAL] CHECK OPTION]

    -- 将视图中的country_name修改为name
    ALTER
    VIEW
    city_country (city_id,city_name,name)
    AS
    SELECT
    c1.id,
    c1.name,
    c2.name
    FROM
    city c1,
    country c2
    WHERE
    c1.cid=c2.id;

视图删除

  • 删除视图

    1
    DROP VIEW 视图名称;
  • 如果存在则删除

    1
    DROP VIEW IF EXISTS 视图名称;

存储过程

基本介绍

存储过程和函数:存储过程和函数是事先经过编译并存储在数据库中的一段 SQL 语句的集合

存储过程和函数的好处:

  • 提高代码的复用性
  • 减少数据在数据库和应用服务器之间的传输,提高传输效率
  • 减少代码层面的业务处理
  • 一次编译永久有效

存储过程和函数的区别:

  • 存储函数必须有返回值
  • 存储过程可以没有返回值

基本操作

DELIMITER:

  • DELIMITER 关键字用来声明 sql 语句的分隔符,告诉 MySQL 该段命令已经结束

  • MySQL 语句默认的分隔符是分号,但是有时需要一条功能 sql 语句中包含分号,但是并不作为结束标识,这时使用 DELIMITER 来指定分隔符:

    1
    DELIMITER 分隔符

存储过程的创建调用查看和删除:

  • 创建存储过程

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    -- 修改分隔符为$
    DELIMITER $

    -- 标准语法
    CREATE PROCEDURE 存储过程名称(参数...)
    BEGIN
    sql语句;
    END$

    -- 修改分隔符为分号
    DELIMITER ;
  • 调用存储过程

    1
    CALL 存储过程名称(实际参数);
  • 查看存储过程

    1
    SELECT * FROM mysql.proc WHERE db='数据库名称';
  • 删除存储过程

    1
    DROP PROCEDURE [IF EXISTS] 存储过程名称;

练习:

  • 数据准备

    1
    2
    3
    4
    5
    id	NAME	age		gender	score
    1 张三 23 男 95
    2 李四 24 男 98
    3 王五 25 女 100
    4 赵六 26 女 90
  • 创建 stu_group() 存储过程,封装分组查询总成绩,并按照总成绩升序排序的功能

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    DELIMITER $

    CREATE PROCEDURE stu_group()
    BEGIN
    SELECT gender,SUM(score) getSum FROM student GROUP BY gender ORDER BY getSum ASC;
    END$

    DELIMITER ;

    -- 调用存储过程
    CALL stu_group();
    -- 删除存储过程
    DROP PROCEDURE IF EXISTS stu_group;

存储语法

变量使用

存储过程是可以进行编程的,意味着可以使用变量、表达式、条件控制语句等,来完成比较复杂的功能

  • 定义变量:DECLARE 定义的是局部变量,只能用在 BEGIN END 范围之内

    1
    DECLARE 变量名 数据类型 [DEFAULT 默认值];
  • 变量的赋值

    1
    2
    SET 变量名 = 变量值;
    SELECT 列名 INTO 变量名 FROM 表名 [WHERE 条件];
  • 数据准备:表 student

    1
    2
    3
    4
    5
    id	NAME	age		gender	score
    1 张三 23 男 95
    2 李四 24 男 98
    3 王五 25 女 100
    4 赵六 26 女 90
  • 定义两个 int 变量,用于存储男女同学的总分数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    DELIMITER $
    CREATE PROCEDURE pro_test3()
    BEGIN
    -- 定义两个变量
    DECLARE men,women INT;
    -- 查询男同学的总分数,为men赋值
    SELECT SUM(score) INTO men FROM student WHERE gender='男';
    -- 查询女同学的总分数,为women赋值
    SELECT SUM(score) INTO women FROM student WHERE gender='女';
    -- 使用变量
    SELECT men,women;
    END$
    DELIMITER ;
    -- 调用存储过程
    CALL pro_test3();

IF语句
  • if 语句标准语法

    1
    2
    3
    4
    5
    IF 判断条件1 THEN 执行的sql语句1;
    [ELSEIF 判断条件2 THEN 执行的sql语句2;]
    ...
    [ELSE 执行的sql语句n;]
    END IF;
  • 数据准备:表 student

    1
    2
    3
    4
    5
    id	NAME	age		gender	score
    1 张三 23 男 95
    2 李四 24 男 98
    3 王五 25 女 100
    4 赵六 26 女 90
  • 根据总成绩判断:全班 380 分及以上学习优秀、320 ~ 380 学习良好、320 以下学习一般

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    DELIMITER $
    CREATE PROCEDURE pro_test4()
    BEGIN
    DECLARE total INT; -- 定义总分数变量
    DECLARE description VARCHAR(10); -- 定义分数描述变量
    SELECT SUM(score) INTO total FROM student; -- 为总分数变量赋值
    -- 判断总分数
    IF total >= 380 THEN
    SET description = '学习优秀';
    ELSEIF total >=320 AND total < 380 THEN
    SET description = '学习良好';
    ELSE
    SET description = '学习一般';
    END IF;
    END$
    DELIMITER ;
    -- 调用pro_test4存储过程
    CALL pro_test4();

参数传递
  • 参数传递的语法

    IN:代表输入参数,需要由调用者传递实际数据,默认的
    OUT:代表输出参数,该参数可以作为返回值
    INOUT:代表既可以作为输入参数,也可以作为输出参数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    DELIMITER $

    -- 标准语法
    CREATE PROCEDURE 存储过程名称([IN|OUT|INOUT] 参数名 数据类型)
    BEGIN
    执行的sql语句;
    END$

    DELIMITER ;
  • 输入总成绩变量,代表学生总成绩,输出分数描述变量,代表学生总成绩的描述

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    DELIMITER $

    CREATE PROCEDURE pro_test6(IN total INT, OUT description VARCHAR(10))
    BEGIN
    -- 判断总分数
    IF total >= 380 THEN
    SET description = '学习优秀';
    ELSEIF total >= 320 AND total < 380 THEN
    SET description = '学习不错';
    ELSE
    SET description = '学习一般';
    END IF;
    END$

    DELIMITER ;
    -- 调用pro_test6存储过程
    CALL pro_test6(310,@description);
    CALL pro_test6((SELECT SUM(score) FROM student), @description);
    -- 查询总成绩描述
    SELECT @description;
  • 查看参数方法

    • @变量名 : 用户会话变量,代表整个会话过程他都是有作用的,类似于全局变量
    • @@变量名 : 系统变量

CASE
  • 标准语法 1

    1
    2
    3
    4
    5
    6
    CASE 表达式
    WHEN 值1 THEN 执行sql语句1;
    [WHEN 值2 THEN 执行sql语句2;]
    ...
    [ELSE 执行sql语句n;]
    END CASE;
  • 标准语法 2

    1
    2
    3
    4
    5
    6
    sCASE
    WHEN 判断条件1 THEN 执行sql语句1;
    [WHEN 判断条件2 THEN 执行sql语句2;]
    ...
    [ELSE 执行sql语句n;]
    END CASE;
  • 演示

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    DELIMITER $
    CREATE PROCEDURE pro_test7(IN total INT)
    BEGIN
    -- 定义变量
    DECLARE description VARCHAR(10);
    -- 使用case判断
    CASE
    WHEN total >= 380 THEN
    SET description = '学习优秀';
    WHEN total >= 320 AND total < 380 THEN
    SET description = '学习不错';
    ELSE
    SET description = '学习一般';
    END CASE;

    -- 查询分数描述信息
    SELECT description;
    END$
    DELIMITER ;
    -- 调用pro_test7存储过程
    CALL pro_test7(390);
    CALL pro_test7((SELECT SUM(score) FROM student));

WHILE
  • while 循环语法

    1
    2
    3
    4
    WHILE 条件判断语句 DO
    循环体语句;
    条件控制语句;
    END WHILE;
  • 计算 1~100 之间的偶数和

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    DELIMITER $
    CREATE PROCEDURE pro_test6()
    BEGIN
    -- 定义求和变量
    DECLARE result INT DEFAULT 0;
    -- 定义初始化变量
    DECLARE num INT DEFAULT 1;
    -- while循环
    WHILE num <= 100 DO
    IF num % 2 = 0 THEN
    SET result = result + num;
    END IF;
    SET num = num + 1;
    END WHILE;
    -- 查询求和结果
    SELECT result;
    END$
    DELIMITER ;

    -- 调用pro_test6存储过程
    CALL pro_test6();

REPEAT
  • repeat 循环标准语法

    1
    2
    3
    4
    5
    6
    初始化语句;
    REPEAT
    循环体语句;
    条件控制语句;
    UNTIL 条件判断语句
    END REPEAT;
  • 计算 1~10 之间的和

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    DELIMITER $
    CREATE PROCEDURE pro_test9()
    BEGIN
    -- 定义求和变量
    DECLARE result INT DEFAULT 0;
    -- 定义初始化变量
    DECLARE num INT DEFAULT 1;
    -- repeat循环
    REPEAT
    -- 累加
    SET result = result + num;
    -- 让num+1
    SET num = num + 1;
    -- 停止循环
    UNTIL num > 10
    END REPEAT;
    -- 查询求和结果
    SELECT result;
    END$

    DELIMITER ;
    -- 调用pro_test9存储过程
    CALL pro_test9();

LOOP

LOOP 实现简单的循环,退出循环的条件需要使用其他的语句定义,通常可以使用 LEAVE 语句实现,如果不加退出循环的语句,那么就变成了死循环

  • loop 循环标准语法

    1
    2
    3
    4
    5
    6
    [循环名称:] LOOP
    条件判断语句
    [LEAVE 循环名称;]
    循环体语句;
    条件控制语句;
    END LOOP 循环名称;
  • 计算 1~10 之间的和

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    DELIMITER $
    CREATE PROCEDURE pro_test10()
    BEGIN
    -- 定义求和变量
    DECLARE result INT DEFAULT 0;
    -- 定义初始化变量
    DECLARE num INT DEFAULT 1;
    -- loop循环
    l:LOOP
    -- 条件成立,停止循环
    IF num > 10 THEN
    LEAVE l;
    END IF;
    -- 累加
    SET result = result + num;
    -- 让num+1
    SET num = num + 1;
    END LOOP l;
    -- 查询求和结果
    SELECT result;
    END$
    DELIMITER ;
    -- 调用pro_test10存储过程
    CALL pro_test10();

游标

游标是用来存储查询结果集的数据类型,在存储过程和函数中可以使用光标对结果集进行循环的处理

  • 游标可以遍历返回的多行结果,每次拿到一整行数据
  • 简单来说游标就类似于集合的迭代器遍历
  • MySQL 中的游标只能用在存储过程和函数中

游标的语法

  • 创建游标

    1
    DECLARE 游标名称 CURSOR FOR 查询sql语句;
  • 打开游标

    1
    OPEN 游标名称;
  • 使用游标获取数据

    1
    FETCH 游标名称 INTO 变量名1,变量名2,...;
  • 关闭游标

    1
    CLOSE 游标名称;
  • Mysql 通过一个 Error handler 声明来判断指针是否到尾部,并且必须和创建游标的 SQL 语句声明在一起:

    1
    DECLARE EXIT HANDLER FOR NOT FOUND (do some action,一般是设置标志变量)

游标的基本使用

  • 数据准备:表 student

    1
    2
    3
    4
    5
    id	NAME	age		gender	score
    1 张三 23 男 95
    2 李四 24 男 98
    3 王五 25 女 100
    4 赵六 26 女 90
  • 创建 stu_score 表

    1
    2
    3
    4
    CREATE TABLE stu_score(
    id INT PRIMARY KEY AUTO_INCREMENT,
    score INT
    );
  • 将student表中所有的成绩保存到stu_score表中

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    DELIMITER $

    CREATE PROCEDURE pro_test12()
    BEGIN
    -- 定义成绩变量
    DECLARE s_score INT;
    -- 定义标记变量
    DECLARE flag INT DEFAULT 0;

    -- 创建游标,查询所有学生成绩数据
    DECLARE stu_result CURSOR FOR SELECT score FROM student;
    -- 游标结束后,将标记变量改为1 这两个必须声明在一起
    DECLARE EXIT HANDLER FOR NOT FOUND SET flag = 1;

    -- 开启游标
    OPEN stu_result;
    -- 循环使用游标
    REPEAT
    -- 使用游标,遍历结果,拿到数据
    FETCH stu_result INTO s_score;
    -- 将数据保存到stu_score表中
    INSERT INTO stu_score VALUES (NULL,s_score);
    UNTIL flag=1
    END REPEAT;
    -- 关闭游标
    CLOSE stu_result;
    END$

    DELIMITER ;

    -- 调用pro_test12存储过程
    CALL pro_test12();
    -- 查询stu_score表
    SELECT * FROM stu_score;

存储函数

存储函数和存储过程是非常相似的,存储函数可以做的事情,存储过程也可以做到

存储函数有返回值,存储过程没有返回值(参数的 out 其实也相当于是返回数据了)

  • 创建存储函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    DELIMITER $
    -- 标准语法
    CREATE FUNCTION 函数名称(参数 数据类型)
    RETURNS 返回值类型
    BEGIN
    执行的sql语句;
    RETURN 结果;
    END$

    DELIMITER ;
  • 调用存储函数,因为有返回值,所以使用 SELECT 调用

    1
    SELECT 函数名称(实际参数);
  • 删除存储函数

    1
    DROP FUNCTION 函数名称;
  • 定义存储函数,获取学生表中成绩大于95分的学生数量

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    DELIMITER $
    CREATE FUNCTION fun_test()
    RETURN INT
    BEGIN
    -- 定义统计变量
    DECLARE result INT;
    -- 查询成绩大于95分的学生数量,给统计变量赋值
    SELECT COUNT(score) INTO result FROM student WHERE score > 95;
    -- 返回统计结果
    SELECT result;
    END
    DELIMITER ;
    -- 调用fun_test存储函数
    SELECT fun_test();

触发器

基本介绍

触发器是与表有关的数据库对象,在 insert/update/delete 之前或之后触发并执行触发器中定义的 SQL 语句

  • 触发器的这种特性可以协助应用在数据库端确保数据的完整性 、日志记录 、数据校验等操作
  • 使用别名 NEW 和 OLD 来引用触发器中发生变化的记录内容,这与其他的数据库是相似的
  • 现在触发器还只支持行级触发,不支持语句级触发
触发器类型 OLD的含义 NEW的含义
INSERT 型触发器 无 (因为插入前状态无数据) NEW 表示将要或者已经新增的数据
UPDATE 型触发器 OLD 表示修改之前的数据 NEW 表示将要或已经修改后的数据
DELETE 型触发器 OLD 表示将要或者已经删除的数据 无 (因为删除后状态无数据)

基本操作

  • 创建触发器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    DELIMITER $

    CREATE TRIGGER 触发器名称
    BEFORE|AFTER INSERT|UPDATE|DELETE
    ON 表名
    [FOR EACH ROW] -- 行级触发器
    BEGIN
    触发器要执行的功能;
    END$

    DELIMITER ;
  • 查看触发器的状态、语法等信息

    1
    SHOW TRIGGERS;
  • 删除触发器,如果没有指定 schema_name,默认为当前数据库

    1
    DROP TRIGGER [schema_name.]trigger_name;

触发演示

通过触发器记录账户表的数据变更日志。包含:增加、修改、删除

  • 数据准备

    1
    2
    3
    4
    -- 创建db9数据库
    CREATE DATABASE db9;
    -- 使用db9数据库
    USE db9;
    1
    2
    3
    4
    5
    6
    7
    8
    -- 创建账户表account
    CREATE TABLE account(
    id INT PRIMARY KEY AUTO_INCREMENT, -- 账户id
    NAME VARCHAR(20), -- 姓名
    money DOUBLE -- 余额
    );
    -- 添加数据
    INSERT INTO account VALUES (NULL,'张三',1000),(NULL,'李四',2000);
    1
    2
    3
    4
    5
    6
    7
    8
    -- 创建日志表account_log
    CREATE TABLE account_log(
    id INT PRIMARY KEY AUTO_INCREMENT, -- 日志id
    operation VARCHAR(20), -- 操作类型 (insert update delete)
    operation_time DATETIME, -- 操作时间
    operation_id INT, -- 操作表的id
    operation_params VARCHAR(200) -- 操作参数
    );
  • 创建 INSERT 型触发器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    DELIMITER $

    CREATE TRIGGER account_insert
    AFTER INSERT
    ON account
    FOR EACH ROW
    BEGIN
    INSERT INTO account_log VALUES (NULL,'INSERT',NOW(),new.id,CONCAT('插入后{id=',new.id,',name=',new.name,',money=',new.money,'}'));
    END$

    DELIMITER ;
    1
    2
    3
    4
    5
    6
    7
    8
    9
    -- 向account表添加记录
    INSERT INTO account VALUES (NULL,'王五',3000);

    -- 查询日志表
    SELECT * FROM account_log;
    /*
    id operation operation_time operation_id operation_params
    1 INSERT 2021-01-26 19:51:11 3 插入后{id=3,name=王五money=2000}
    */
  • 创建 UPDATE 型触发器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    DELIMITER $

    CREATE TRIGGER account_update
    AFTER UPDATE
    ON account
    FOR EACH ROW
    BEGIN
    INSERT INTO account_log VALUES (NULL,'UPDATE',NOW(),new.id,CONCAT('修改前{id=',old.id,',name=',old.name,',money=',old.money,'}','修改后{id=',new.id,',name=',new.name,',money=',new.money,'}'));
    END$

    DELIMITER ;
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    -- 修改account表
    UPDATE account SET money=3500 WHERE id=3;

    -- 查询日志表
    SELECT * FROM account_log;
    /*
    id operation operation_time operation_id operation_params
    2 UPDATE 2021-01-26 19:58:54 2 更新前{id=2,name=李四money=1000}
    更新后{id=2,name=李四money=200}
    */
  • 创建 DELETE 型触发器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    DELIMITER $

    CREATE TRIGGER account_delete
    AFTER DELETE
    ON account
    FOR EACH ROW
    BEGIN
    INSERT INTO account_log VALUES (NULL,'DELETE',NOW(),old.id,CONCAT('删除前{id=',old.id,',name=',old.name,',money=',old.money,'}'));
    END$

    DELIMITER ;
    1
    2
    3
    4
    5
    6
    7
    8
    9
    -- 删除account表数据
    DELETE FROM account WHERE id=3;

    -- 查询日志表
    SELECT * FROM account_log;
    /*
    id operation operation_time operation_id operation_params
    3 DELETE 2021-01-26 20:02:48 3 删除前{id=3,name=王五money=2000}
    */

存储引擎

基本介绍

对比其他数据库,MySQL 的架构可以在不同场景应用并发挥良好作用,主要体现在存储引擎,插件式的存储引擎架构将查询处理和其他的系统任务以及数据的存储提取分离,可以针对不同的存储需求可以选择最优的存储引擎

存储引擎的介绍:

  • MySQL 数据库使用不同的机制存取表文件 , 机制的差别在于不同的存储方式、索引技巧、锁定水平等不同的功能和能力,在 MySQL 中,将这些不同的技术及配套的功能称为存储引擎
  • Oracle、SqlServer 等数据库只有一种存储引擎,MySQL 提供了插件式的存储引擎架构,所以 MySQL 存在多种存储引擎 , 就会让数据库采取了不同的处理数据的方式和扩展功能
  • 在关系型数据库中数据的存储是以表的形式存进行,所以存储引擎也称为表类型(存储和操作此表的类型)
  • 通过选择不同的引擎,能够获取最佳的方案, 也能够获得额外的速度或者功能,提高程序的整体效果。

MySQL 支持的存储引擎:

  • MySQL 支持的引擎包括:InnoDB、MyISAM、MEMORY、Archive、Federate、CSV、BLACKHOLE 等
  • MySQL5.5 之前的默认存储引擎是 MyISAM,5.5 之后就改为了 InnoDB

引擎对比

MyISAM 存储引擎:

  • 特点:不支持事务和外键,读取速度快,节约资源
  • 应用场景:查询和插入操作为主,只有很少更新和删除操作,并对事务的完整性、并发性要求不高
  • 存储方式:
    • 每个 MyISAM 在磁盘上存储成 3 个文件,其文件名都和表名相同,拓展名不同
    • 表的定义保存在 .frm 文件,表数据保存在 .MYD (MYData) 文件中,索引保存在 .MYI (MYIndex) 文件中

InnoDB 存储引擎:(MySQL5.5 版本后默认的存储引擎)

  • 特点:支持事务和外键操作,支持并发控制。对比 MyISAM 的存储引擎,InnoDB 写的处理效率差一些,并且会占用更多的磁盘空间以保留数据和索引
  • 应用场景:对事务的完整性有比较高的要求,在并发条件下要求数据的一致性,读写频繁的操作
  • 存储方式:
    • 使用共享表空间存储, 这种方式创建的表的表结构保存在 .frm 文件中, 数据和索引保存在 innodb_data_home_dir 和 innodb_data_file_path 定义的表空间中,可以是多个文件
    • 使用多表空间存储,创建的表的表结构存在 .frm 文件中,每个表的数据和索引单独保存在 .ibd 中

MEMORY 存储引擎:

  • 特点:每个 MEMORY 表实际对应一个磁盘文件 ,该文件中只存储表的结构,表数据保存在内存中,且默认使用 HASH 索引,所以数据默认就是无序的,但是在需要快速定位记录可以提供更快的访问,服务一旦关闭,表中的数据就会丢失,存储不安全
  • 应用场景:通常用于更新不太频繁的小表,用以快速得到访问结果,类似缓存
  • 存储方式:表结构保存在 .frm 中

MERGE 存储引擎:

  • 特点:

    • 是一组 MyISAM 表的组合,这些 MyISAM 表必须结构完全相同,通过将不同的表分布在多个磁盘上
    • MERGE 表本身并没有存储数据,对 MERGE 类型的表可以进行查询、更新、删除操作,这些操作实际上是对内部的 MyISAM 表进行的
  • 应用场景:将一系列等同的 MyISAM 表以逻辑方式组合在一起,并作为一个对象引用他们,适合做数据仓库

  • 操作方式:

    • 插入操作是通过 INSERT_METHOD 子句定义插入的表,使用 FIRST 或 LAST 值使得插入操作被相应地作用在第一或者最后一个表上;不定义这个子句或者定义为 NO,表示不能对 MERGE 表执行插入操作
    • 对 MERGE 表进行 DROP 操作,但是这个操作只是删除 MERGE 表的定义,对内部的表是没有任何影响的
    1
    2
    3
    4
    5
    6
    7
    8
    9
    CREATE TABLE order_1(
    )ENGINE = MyISAM DEFAULT CHARSET=utf8;

    CREATE TABLE order_2(
    )ENGINE = MyISAM DEFAULT CHARSET=utf8;

    CREATE TABLE order_all(
    -- 结构与MyISAM表相同
    )ENGINE = MERGE UNION = (order_1,order_2) INSERT_METHOD=LAST DEFAULT CHARSET=utf8;

特性 MyISAM InnoDB MEMORY
存储限制 有(平台对文件系统大小的限制) 64TB 有(平台的内存限制)
事务安全 不支持 支持 不支持
锁机制 表锁 表锁/行锁 表锁
B+Tree 索引 支持 支持 支持
哈希索引 不支持 不支持 支持
全文索引 支持 支持 不支持
集群索引 不支持 支持 不支持
数据索引 不支持 支持 支持
数据缓存 不支持 支持 N/A
索引缓存 支持 支持 N/A
数据可压缩 支持 不支持 不支持
空间使用 N/A
内存使用 中等
批量插入速度
外键 不支持 支持 不支持

MyISAM 和 InnoDB 的区别?

  • 事务:InnoDB 支持事务,MyISAM 不支持事务

  • 外键:InnoDB 支持外键,MyISAM 不支持外键

  • 索引:InnoDB 是聚集(聚簇)索引,MyISAM 是非聚集(非聚簇)索引

  • 锁粒度:InnoDB 最小的锁粒度是行锁,MyISAM 最小的锁粒度是表锁

  • 存储结构:参考本节上半部分


引擎操作

  • 查询数据库支持的存储引擎

    1
    2
    SHOW ENGINES;
    SHOW VARIABLES LIKE '%storage_engine%'; -- 查看Mysql数据库默认的存储引擎
  • 查询某个数据库中所有数据表的存储引擎

    1
    SHOW TABLE STATUS FROM 数据库名称;
  • 查询某个数据库中某个数据表的存储引擎

    1
    SHOW TABLE STATUS FROM 数据库名称 WHERE NAME = '数据表名称';
  • 创建数据表,指定存储引擎

    1
    2
    3
    4
    CREATE TABLE 表名(
    列名,数据类型,
    ...
    )ENGINE = 引擎名称;
  • 修改数据表的存储引擎

    1
    ALTER TABLE 表名 ENGINE = 引擎名称;

索引机制

索引介绍

基本介绍

MySQL 官方对索引的定义为:索引(index)是帮助 MySQL 高效获取数据的一种数据结构,本质是排好序的快速查找数据结构。在表数据之外,数据库系统还维护着满足特定查找算法的数据结构,这些数据结构以某种方式指向数据, 这样就可以在这些数据结构上实现高级查找算法,这种数据结构就是索引

索引是在存储引擎层实现的,所以并没有统一的索引标准,即不同存储引擎的索引的工作方式并不一样

索引使用:一张数据表,用于保存数据;一个索引配置文件,用于保存索引;每个索引都指向了某一个数据

左边是数据表,一共有两列七条记录,最左边的是数据记录的物理地址(注意逻辑上相邻的记录在磁盘上也并不是一定物理相邻的)。为了加快 Col2 的查找,可以维护一个右边所示的二叉查找树,每个节点分别包含索引键值和一个指向对应数据的物理地址的指针,这样就可以运用二叉查找快速获取到相应数据

索引的优点:

  • 类似于书籍的目录索引,提高数据检索的效率,降低数据库的 IO 成本
  • 通过索引列对数据进行排序,降低数据排序的成本,降低 CPU 的消耗

索引的缺点:

  • 一般来说索引本身也很大,不可能全部存储在内存中,因此索引往往以索引文件的形式存储在磁盘
  • 虽然索引大大提高了查询效率,同时却也降低更新表的速度。对表进行 INSERT、UPDATE、DELETE 操作,MySQL 不仅要保存数据,还要保存一下索引文件每次更新添加了索引列的字段,还会调整因为更新所带来的键值变化后的索引信息,但是更新数据也需要先从数据库中获取,索引加快了获取速度,所以可以相互抵消一下。
  • 索引会影响到 WHERE 的查询条件和排序 ORDER BY 两大功能

索引分类

索引一般的分类如下:

  • 功能分类

    • 主键索引:一种特殊的唯一索引,不允许有空值,一般在建表时同时创建主键索引
    • 单列索引:一个索引只包含单个列,一个表可以有多个单列索引(普通索引)
    • 联合索引:顾名思义,就是将单列索引进行组合
    • 唯一索引:索引列的值必须唯一,允许有空值,如果是联合索引,则列值组合必须唯一
      • NULL 值可以出现多次,因为两个 NULL 比较的结果既不相等,也不不等,结果仍然是未知
      • 可以声明不允许存储 NULL 值的非空唯一索引
    • 外键索引:只有 InnoDB 引擎支持外键索引,用来保证数据的一致性、完整性和实现级联操作
  • 结构分类

    • BTree 索引:MySQL 使用最频繁的一个索引数据结构,是 InnoDB 和 MyISAM 存储引擎默认的索引类型,底层基于 B+Tree
    • Hash 索引:MySQL中 Memory 存储引擎默认支持的索引类型
    • R-tree 索引(空间索引):空间索引是 MyISAM 引擎的一个特殊索引类型,主要用于地理空间数据类型
    • Full-text 索引(全文索引):快速匹配全部文档的方式。MyISAM 支持, InnoDB 不支持 FULLTEXT 类型的索引,但是 InnoDB 可以使用 sphinx 插件支持全文索引,MEMORY 引擎不支持
    索引 InnoDB MyISAM Memory
    BTREE 支持 支持 支持
    HASH 不支持 不支持 支持
    R-tree 不支持 支持 不支持
    Full-text 5.6 版本之后支持 支持 不支持

联合索引图示:根据身高年龄建立的组合索引(height、age)


索引操作

索引在创建表的时候可以同时创建, 也可以随时增加新的索引

  • 创建索引:如果一个表中有一列是主键,那么会默认为其创建主键索引(主键列不需要单独创建索引)

    1
    2
    CREATE [UNIQUE|FULLTEXT] INDEX 索引名称 [USING 索引类型] ON 表名(列名...);
    -- 索引类型默认是 B+TREE
  • 查看索引

    1
    SHOW INDEX FROM 表名;
  • 添加索引

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    -- 单列索引
    ALTER TABLE 表名 ADD INDEX 索引名称(列名);

    -- 组合索引
    ALTER TABLE 表名 ADD INDEX 索引名称(列名1,列名2,...);

    -- 主键索引
    ALTER TABLE 表名 ADD PRIMARY KEY(主键列名);

    -- 外键索引(添加外键约束,就是外键索引)
    ALTER TABLE 表名 ADD CONSTRAINT 外键名 FOREIGN KEY (本表外键列名) REFERENCES 主表名(主键列名);

    -- 唯一索引
    ALTER TABLE 表名 ADD UNIQUE 索引名称(列名);

    -- 全文索引(mysql只支持文本类型)
    ALTER TABLE 表名 ADD FULLTEXT 索引名称(列名);
  • 删除索引

    1
    DROP INDEX 索引名称 ON 表名;
  • 案例练习

    数据准备:student

    1
    2
    3
    4
    5
    id	NAME	 age	score
    1 张三 23 99
    2 李四 24 95
    3 王五 25 98
    4 赵六 26 97

    索引操作:

    1
    2
    3
    4
    5
    -- 为student表中姓名列创建一个普通索引
    CREATE INDEX idx_name ON student(NAME);

    -- 为student表中年龄列创建一个唯一索引
    CREATE UNIQUE INDEX idx_age ON student(age);

聚簇索引

索引对比

聚簇索引是一种数据存储方式,并不是一种单独的索引类型

  • 聚簇索引的叶子节点存放的是主键值和数据行,支持覆盖索引

  • 非聚簇索引的叶子节点存放的是主键值或指向数据行的指针(由存储引擎决定)

在 Innodb 下主键索引是聚簇索引,在 MyISAM 下主键索引是非聚簇索引


Innodb

聚簇索引

在 Innodb 存储引擎,B+ 树索引可以分为聚簇索引(也称聚集索引、clustered index)和辅助索引(也称非聚簇索引或二级索引、secondary index、non-clustered index)

InnoDB 中,聚簇索引是按照每张表的主键构造一颗 B+ 树,叶子节点中存放的就是整张表的数据,将聚簇索引的叶子节点称为数据页

  • 这个特性决定了数据也是索引的一部分,所以一张表只能有一个聚簇索引
  • 辅助索引的存在不影响聚簇索引中数据的组织,所以一张表可以有多个辅助索引

聚簇索引的优点:

  • 数据访问更快,聚簇索引将索引和数据保存在同一个 B+ 树中,因此从聚簇索引中获取数据比非聚簇索引更快
  • 聚簇索引对于主键的排序查找和范围查找速度非常快

聚簇索引的缺点:

  • 插入速度严重依赖于插入顺序,按照主键的顺序(递增)插入是最快的方式,否则将会出现页分裂,严重影响性能,所以对于 InnoDB 表,一般都会定义一个自增的 ID 列为主键

  • 更新主键的代价很高,将会导致被更新的行移动,所以对于 InnoDB 表,一般定义主键为不可更新

  • 二级索引访问需要两次索引查找,第一次找到主键值,第二次根据主键值找到行数据


辅助索引

在聚簇索引之上创建的索引称之为辅助索引,非聚簇索引都是辅助索引,像复合索引、前缀索引、唯一索引等

辅助索引叶子节点存储的是主键值,而不是数据的物理地址,所以访问数据需要二次查找,推荐使用覆盖索引,可以减少回表查询

检索过程:辅助索引找到主键值,再通过聚簇索引(二分)找到数据页,最后通过数据页中的 Page Directory(二分)找到对应的数据分组,遍历组内所所有的数据找到数据行

补充:无索引走全表查询,查到数据页后和上述步骤一致


索引实现

InnoDB 使用 B+Tree 作为索引结构,并且 InnoDB 一定有索引

主键索引:

  • 在 InnoDB 中,表数据文件本身就是按 B+Tree 组织的一个索引结构,这个索引的 key 是数据表的主键,叶子节点 data 域保存了完整的数据记录

  • InnoDB 的表数据文件通过主键聚集数据,如果没有定义主键,会选择非空唯一索引代替,如果也没有这样的列,MySQL 会自动为 InnoDB 表生成一个隐含字段 row_id 作为主键,这个字段长度为 6 个字节,类型为长整形

辅助索引:

  • InnoDB 的所有辅助索引(二级索引)都引用主键作为 data 域

  • InnoDB 表是基于聚簇索引建立的,因此 InnoDB 的索引能提供一种非常快速的主键查找性能。不过辅助索引也会包含主键列,所以不建议使用过长的字段作为主键,过长的主索引会令辅助索引变得过大


MyISAM

非聚簇

MyISAM 的主键索引使用的是非聚簇索引,索引文件和数据文件是分离的,索引文件仅保存数据的地址

  • 主键索引 B+ 树的节点存储了主键,辅助键索引 B+ 树存储了辅助键,表数据存储在独立的地方,这两颗 B+ 树的叶子节点都使用一个地址指向真正的表数据,对于表数据来说,这两个键没有任何差别
  • 由于索引树是独立的,通过辅助索引检索无需回表查询访问主键的索引树


索引实现

MyISAM 的索引方式也叫做非聚集的,之所以这么称呼是为了与 InnoDB 的聚集索引区分

主键索引:MyISAM 引擎使用 B+Tree 作为索引结构,叶节点的 data 域存放的是数据记录的地址

辅助索引:MyISAM 中主索引和辅助索引(Secondary key)在结构上没有任何区别,只是主索引要求 key 是唯一的,而辅助索引的 key 可以重复

参考文章:https://blog.csdn.net/lm1060891265/article/details/81482136


索引结构

数据页

文件系统的最小单元是块(block),一个块的大小是 4K,系统从磁盘读取数据到内存时是以磁盘块为基本单位的,位于同一个磁盘块中的数据会被一次性读取出来,而不是需要什么取什么

InnoDB 存储引擎中有页(Page)的概念,页是 MySQL 磁盘管理的最小单位

  • InnoDB 存储引擎中默认每个页的大小为 16KB,索引中一个节点就是一个数据页,所以会一次性读取 16KB 的数据到内存
  • InnoDB 引擎将若干个地址连接磁盘块,以此来达到页的大小 16KB
  • 在查询数据时如果一个页中的每条数据都能有助于定位数据记录的位置,这将会减少磁盘 I/O 次数,提高查询效率

超过 16KB 的一条记录,主键索引页只会存储部分数据和指向溢出页的指针,剩余数据都会分散存储在溢出页中

数据页物理结构,从上到下:

  • File Header:上一页和下一页的指针、该页的类型(索引页、数据页、日志页等)、校验和、LSN(最近一次修改当前页面时的系统 lsn 值,事务持久性部分详解)等信息
  • Page Header:记录状态信息
  • Infimum + Supremum:当前页的最小记录和最大记录(头尾指针),Infimum 所在分组只有一条记录,Supremum 所在分组可以有 1 ~ 8 条记录,剩余的分组可以有 4 ~ 8 条记录
  • User Records:存储数据的记录
  • Free Space:尚未使用的存储空间
  • Page Directory:分组的目录,可以通过目录快速定位(二分法)数据的分组
  • File Trailer:检验和字段,在刷脏过程中,页首和页尾的校验和一致才能说明页面刷新成功,二者不同说明刷新期间发生了错误;LSN 字段,也是用来校验页面的完整性

数据页中包含数据行,数据的存储是基于数据行的,数据行有 next_record 属性指向下一个行数据,所以是可以遍历的,但是一组数据至多 8 个行,通过 Page Directory 先定位到组,然后遍历获取所需的数据行即可

数据行中有三个隐藏字段:trx_id、roll_pointer、row_id(在事务章节会详细介绍它们的作用)


BTree

BTree 的索引类型是基于 B+Tree 树型数据结构的,B+Tree 又是 BTree 数据结构的变种,用在数据库和操作系统中的文件系统,特点是能够保持数据稳定有序

BTree 又叫多路平衡搜索树,一颗 m 叉的 BTree 特性如下:

  • 树中每个节点最多包含 m 个孩子
  • 除根节点与叶子节点外,每个节点至少有 [ceil(m/2)] 个孩子
  • 若根节点不是叶子节点,则至少有两个孩子
  • 所有的叶子节点都在同一层
  • 每个非叶子节点由 n 个 key 与 n+1 个指针组成,其中 [ceil(m/2)-1] <= n <= m-1

5 叉,key 的数量 [ceil(m/2)-1] <= n <= m-1 为 2 <= n <=4 ,当 n>4 时中间节点分裂到父节点,两边节点分裂

插入 C N G A H E K Q M F W L T Z D P R X Y S 数据的工作流程:

  • 插入前 4 个字母 C N G A

  • 插入 H,n>4,中间元素 G 字母向上分裂到新的节点

  • 插入 E、K、Q 不需要分裂

  • 插入 M,中间元素 M 字母向上分裂到父节点 G

  • 插入 F,W,L,T 不需要分裂

  • 插入 Z,中间元素 T 向上分裂到父节点中

  • 插入 D,中间元素 D 向上分裂到父节点中,然后插入 P,R,X,Y 不需要分裂

  • 最后插入 S,NPQR 节点 n>5,中间节点 Q 向上分裂,但分裂后父节点 DGMT 的 n>5,中间节点 M 向上分裂

BTree 树就已经构建完成了,BTree 树和二叉树相比, 查询数据的效率更高, 因为对于相同的数据量来说,BTree 的层级结构比二叉树少,所以搜索速度快

BTree 结构的数据可以让系统高效的找到数据所在的磁盘块,定义一条记录为一个二元组 [key, data] ,key 为记录的键值,对应表中的主键值,data 为一行记录中除主键外的数据。对于不同的记录,key 值互不相同,BTree 中的每个节点根据实际情况可以包含大量的关键字信息和分支

缺点:当进行范围查找时会出现回旋查找


B+Tree

数据结构

BTree 数据结构中每个节点中不仅包含数据的 key 值,还有 data 值。磁盘中每一页的存储空间是有限的,如果 data 数据较大时将会导致每个节点(即一个页)能存储的 key 的数量很小,当存储的数据量很大时同样会导致 B-Tree 的深度较大,增大查询时的磁盘 I/O 次数,进而影响查询效率,所以引入 B+Tree

B+Tree 为 BTree 的变种,B+Tree 与 BTree 的区别为:

  • n 叉 B+Tree 最多含有 n 个 key(哈希值),而 BTree 最多含有 n-1 个 key
  • 所有非叶子节点只存储键值 key 信息,只进行数据索引,使每个非叶子节点所能保存的关键字大大增加
  • 所有数据都存储在叶子节点,所以每次数据查询的次数都一样
  • 叶子节点按照 key 大小顺序排列,左边结尾数据都会保存右边节点开始数据的指针,形成一个链表
  • 所有节点中的 key 在叶子节点中也存在(比如 5),key 允许重复,B 树不同节点不存在重复的 key

B* 树:是 B+ 树的变体,在 B+ 树的非根和非叶子结点再增加指向兄弟的指针


优化结构

MySQL 索引数据结构对经典的 B+Tree 进行了优化,在原 B+Tree 的基础上,增加一个指向相邻叶子节点的链表指针,就形成了带有顺序指针的 B+Tree,提高区间访问的性能,防止回旋查找

区间访问的意思是访问索引为 5 - 15 的数据,可以直接根据相邻节点的指针遍历

B+ 树的叶子节点是数据页(page),一个页里面可以存多个数据行

通常在 B+Tree 上有两个头指针,一个指向根节点,另一个指向关键字最小的叶子节点,而且所有叶子节点(即数据节点)之间是一种链式环结构。可以对 B+Tree 进行两种查找运算:

  • 有范围:对于主键的范围查找和分页查找
  • 有顺序:从根节点开始,进行随机查找,顺序查找

InnoDB 中每个数据页的大小默认是 16KB,

  • 索引行:一般表的主键类型为 INT(4 字节)或 BIGINT(8 字节),指针大小在 InnoDB 中设置为 6 字节节,也就是说一个页大概存储 16KB/(8B+6B)=1K 个键值(估值)。则一个深度为 3 的 B+Tree 索引可以维护 10^3 * 10^3 * 10^3 = 10亿 条记录
  • 数据行:一行数据的大小可能是 1k,一个数据页可以存储 16 行

实际情况中每个节点可能不能填充满,因此在数据库中,B+Tree 的高度一般都在 2-4 层。MySQL 的 InnoDB 存储引擎在设计时是将根节点常驻内存的,也就是说查找某一键值的行记录时最多只需要 1~3 次磁盘 I/O 操作

B+Tree 优点:提高查询速度,减少磁盘的 IO 次数,树形结构较小


索引维护

B+ 树为了保持索引的有序性,在插入新值的时候需要做相应的维护

每个索引中每个块存储在磁盘页中,可能会出现以下两种情况:

  • 如果所在的数据页已经满了,这时候需要申请一个新的数据页,然后挪动部分数据过去,这个过程称为页分裂,原本放在一个页的数据现在分到两个页中,降低了空间利用率
  • 当相邻两个页由于删除了数据,利用率很低之后,会将数据页做页合并,合并的过程可以认为是分裂过程的逆过程
  • 这两个情况都是由 B+ 树的结构决定的

一般选用数据小的字段做索引,字段长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小

自增主键的插入数据模式,可以让主键索引尽量地保持递增顺序插入,不涉及到挪动其他记录,避免了页分裂,页分裂的目的就是保证后一个数据页中的所有行主键值比前一个数据页中主键值大

参考文章:https://developer.aliyun.com/article/919861


设计原则

索引的设计可以遵循一些已有的原则,创建索引的时候请尽量考虑符合这些原则,便于提升索引的使用效率

创建索引时的原则:

  • 对查询频次较高,且数据量比较大的表建立索引
  • 使用唯一索引,区分度越高,使用索引的效率越高
  • 索引字段的选择,最佳候选列应当从 where 子句的条件中提取,使用覆盖索引
  • 使用短索引,索引创建之后也是使用硬盘来存储的,因此提升索引访问的 I/O 效率,也可以提升总体的访问效率。假如构成索引的字段总长度比较短,那么在给定大小的存储块内可以存储更多的索引值,相应的可以有效的提升 MySQL 访问索引的 I/O 效率
  • 索引可以有效的提升查询数据的效率,但索引数量不是多多益善,索引越多,维护索引的代价越高。对于插入、更新、删除等 DML 操作比较频繁的表来说,索引过多,会引入相当高的维护代价,降低 DML 操作的效率,增加相应操作的时间消耗;另外索引过多的话,MySQL 也会犯选择困难病,虽然最终仍然会找到一个可用的索引,但提高了选择的代价
  • MySQL 建立联合索引时会遵守最左前缀匹配原则,即最左优先,在检索数据时从联合索引的最左边开始匹配

    N 个列组合而成的组合索引,相当于创建了 N 个索引,如果查询时 where 句中使用了组成该索引的几个字段,那么这条查询 SQL 可以利用组合索引来提升查询效率

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    -- 对name、address、phone列建一个联合索引
    ALTER TABLE user ADD INDEX index_three(name,address,phone);
    -- 查询语句执行时会依照最左前缀匹配原则,检索时分别会使用索引进行数据匹配。
    (name,address,phone)
    (name,address)
    (name,phone) -- 只有name字段走了索引
    (name)

    -- 索引的字段可以是任意顺序的,优化器会帮助我们调整顺序,下面的SQL语句可以命中索引
    SELECT * FROM user WHERE address = '北京' AND phone = '12345' AND name = '张三';
    1
    2
    -- 如果联合索引中最左边的列不包含在条件查询中,SQL语句就不会命中索引,比如:
    SELECT * FROM user WHERE address = '北京' AND phone = '12345';

哪些情况不要建立索引:

  • 记录太少的表
  • 经常增删改的表
  • 频繁更新的字段不适合创建索引
  • where 条件里用不到的字段不创建索引

索引优化

覆盖索引

覆盖索引:包含所有满足查询需要的数据的索引(SELECT 后面的字段刚好是索引字段),可以利用该索引返回 SELECT 列表的字段,而不必根据索引去聚簇索引上读取数据文件

回表查询:要查找的字段不在非主键索引树上时,需要通过叶子节点的主键值去主键索引上获取对应的行数据

使用覆盖索引,防止回表查询:

  • 表 user 主键为 id,普通索引为 age,查询语句:

    1
    SELECT * FROM user WHERE age = 30;

    查询过程:先通过普通索引 age=30 定位到主键值 id=1,再通过聚集索引 id=1 定位到行记录数据,需要两次扫描 B+ 树

  • 使用覆盖索引:

    1
    2
    3
    DROP INDEX idx_age ON user;
    CREATE INDEX idx_age_name ON user(age,name);
    SELECT id,age FROM user WHERE age = 30;

    在一棵索引树上就能获取查询所需的数据,无需回表速度更快

使用覆盖索引,要注意 SELECT 列表中只取出需要的列,不可用 SELECT *,所有字段一起做索引会导致索引文件过大,查询性能下降


索引下推

索引条件下推优化(Index Condition Pushdown,ICP)是 MySQL5.6 添加,可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数

索引下推充分利用了索引中的数据,在查询出整行数据之前过滤掉无效的数据,再去主键索引树上查找

  • 不使用索引下推优化时存储引擎通过索引检索到数据,然后回表查询记录返回给 Server 层,服务器判断数据是否符合条件

  • 使用索引下推优化时,如果存在某些被索引的列的判断条件时,由存储引擎在索引遍历的过程中判断数据是否符合传递的条件,将符合条件的数据进行回表,检索出来返回给服务器,由此减少 IO 次数

适用条件

  • 需要存储引擎将索引中的数据与条件进行判断(所以条件列必须都在同一个索引中),所以优化是基于存储引擎的,只有特定引擎可以使用,适用于 InnoDB 和 MyISAM
  • 存储引擎没有调用跨存储引擎的能力,跨存储引擎的功能有存储过程、触发器、视图,所以调用这些功能的不可以进行索引下推优化
  • 对于 InnoDB 引擎只适用于二级索引,InnoDB 的聚簇索引会将整行数据读到缓冲区,不再需要去回表查询了

工作过程:用户表 user,(name, age) 是联合索引

1
SELECT * FROM user WHERE name LIKE '张%' AND age = 10;	-- 头部模糊匹配会造成索引失效
  • 优化前:在非主键索引树上找到满足第一个条件的行,然后通过叶子节点记录的主键值再回到主键索引树上查找到对应的行数据,再对比 AND 后的条件是否符合,符合返回数据,需要 4 次回表

  • 优化后:检查索引中存储的列信息是否符合索引条件,然后交由存储引擎用剩余的判断条件判断此行数据是否符合要求,不满足条件的不去读取表中的数据,满足下推条件的就根据主键值进行回表查询,2 次回表

当使用 EXPLAIN 进行分析时,如果使用了索引条件下推,Extra 会显示 Using index condition

参考文章:https://blog.csdn.net/sinat_29774479/article/details/103470244

参考文章:https://time.geekbang.org/column/article/69636


前缀索引

当要索引的列字符很多时,索引会变大变慢,可以只索引列开始的部分字符串,节约索引空间,提高索引效率

注意:使用前缀索引就系统就忽略覆盖索引对查询性能的优化了

优化原则:降低重复的索引值

比如地区表:

1
2
3
4
5
6
area			gdp		code
chinaShanghai 100 aaa
chinaDalian 200 bbb
usaNewYork 300 ccc
chinaFuxin 400 ddd
chinaBeijing 500 eee

发现 area 字段很多都是以 china 开头的,那么如果以前 1-5 位字符做前缀索引就会出现大量索引值重复的情况,索引值重复性越低,查询效率也就越高,所以需要建立前 6 位字符的索引:

1
CREATE INDEX idx_area ON table_name(area(7));

场景:存储身份证

  • 直接创建完整索引,这样可能比较占用空间
  • 创建前缀索引,节省空间,但会增加查询扫描次数,并且不能使用覆盖索引
  • 倒序存储,再创建前缀索引,用于绕过字符串本身前缀的区分度不够的问题(前 6 位相同的很多)
  • 创建 hash 字段索引,查询性能稳定,有额外的存储和计算消耗,跟第三种方式一样,都不支持范围扫描

索引合并

使用多个索引来完成一次查询的执行方法叫做索引合并 index merge

  • Intersection 索引合并:

    1
    SELECT * FROM table_test WHERE key1 = 'a' AND key3 = 'b'; # key1 和 key3 列都是单列索引、二级索引

    从不同索引中扫描到的记录的 id 值取交集(相同 id),然后执行回表操作,要求从每个二级索引获取到的记录都是按照主键值排序

  • Union 索引合并:

    1
    SELECT * FROM table_test WHERE key1 = 'a' OR key3 = 'b';

    从不同索引中扫描到的记录的 id 值取并集,然后执行回表操作,要求从每个二级索引获取到的记录都是按照主键值排序

  • Sort-Union 索引合并

    1
    SELECT * FROM table_test WHERE key1 < 'a' OR key3 > 'b';

    先将从不同索引中扫描到的记录的主键值进行排序,再按照 Union 索引合并的方式进行查询

索引合并算法的效率并不好,通过将其中的一个索引改成联合索引会优化效率


系统优化

表优化

分区表

基本介绍

分区表是将大表的数据按分区字段分成许多小的子集,建立一个以 ftime 年份为分区的表:

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE `t` (
`ftime` datetime NOT NULL,
`c` int(11) DEFAULT NULL,
KEY (`ftime`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1
PARTITION BY RANGE (YEAR(ftime))
(PARTITION p_2017 VALUES LESS THAN (2017) ENGINE = InnoDB,
PARTITION p_2018 VALUES LESS THAN (2018) ENGINE = InnoDB,
PARTITION p_2019 VALUES LESS THAN (2019) ENGINE = InnoDB,
PARTITION p_others VALUES LESS THAN MAXVALUE ENGINE = InnoDB);
INSERT INTO t VALUES('2017-4-1',1),('2018-4-1',1);-- 这两行记录分别落在 p_2018 和 p_2019 这两个分区上

这个表包含了一个.frm 文件和 4 个.ibd 文件,每个分区对应一个.ibd 文件

  • 对于引擎层来说,这是 4 个表,针对每个分区表的操作不会相互影响
  • 对于 Server 层来说,这是 1 个表

分区策略

打开表行为:第一次访问一个分区表时,MySQL 需要把所有的分区都访问一遍,如果分区表的数量很多,超过了 open_files_limit 参数(默认值 1024),那么就会在访问这个表时打开所有的文件,导致打开表文件的个数超过了上限而报错

通用分区策略:MyISAM 分区表使用的分区策略,每次访问分区都由 Server 层控制,在文件管理、表管理的实现上很粗糙,因此有比较严重的性能问题

本地分区策略:从 MySQL 5.7.9 开始,InnoDB 引擎内部自己管理打开分区的行为,InnoDB 引擎打开文件超过 innodb_open_files 时就会关掉一些之前打开的文件,所以即使分区个数大于 open_files_limit,也不会报错

从 MySQL 8.0 版本开始,就不允许创建 MyISAM 分区表,只允许创建已经实现了本地分区策略的引擎,目前只有 InnoDB 和 NDB 这两个引擎支持了本地分区策略


Server 层

从 Server 层看一个分区表就只是一个表

  • Session A:

    1
    SELECT * FROM t WHERE ftime = '2018-4-1';
  • Session B:

    1
    ALTER TABLE t TRUNCATE PARTITION p_2017; -- blocked

现象:Session B 只操作 p_2017 分区,但是由于 Session A 持有整个表 t 的 MDL 读锁,就导致 B 的 ALTER 语句获取 MDL 写锁阻塞

分区表的特点:

  • 第一次访问的时候需要访问所有分区
  • 在 Server 层认为这是同一张表,因此所有分区共用同一个 MDL 锁
  • 在引擎层认为这是不同的表,因此 MDL 锁之后的执行过程,会根据分区表规则,只访问需要的分区

应用场景

分区表的优点:

  • 对业务透明,相对于用户分表来说,使用分区表的业务代码更简洁

  • 分区表可以很方便的清理历史数据。按照时间分区的分区表,就可以直接通过 alter table t drop partition 这个语法直接删除分区文件,从而删掉过期的历史数据,与使用 drop 语句删除数据相比,优势是速度快、对系统影响小

使用分区表,不建议创建太多的分区,注意事项:

  • 分区并不是越细越好,单表或者单分区的数据一千万行,只要没有特别大的索引,对于现在的硬件能力来说都已经是小表
  • 分区不要提前预留太多,在使用之前预先创建即可。比如是按月分区,每年年底时再把下一年度的 12 个新分区创建上即可,并且对于没有数据的历史分区,要及时的 drop 掉

参考文档:https://time.geekbang.org/column/article/82560


临时表

基本介绍

临时表分为内部临时表和用户临时表

  • 内部临时表:系统执行 SQL 语句优化时产生的表,例如 Join 连接查询、去重查询等

  • 用户临时表:用户主动创建的临时表

    1
    CREATE TEMPORARY TABLE temp_t like table_1;

临时表可以是内存表,也可以是磁盘表(多表操作 → 嵌套查询章节提及)

  • 内存表指的是使用 Memory 引擎的表,建立哈希索引,建表语法是 create table … engine=memory,这种表的数据都保存在内存里,系统重启时会被清空,但是表结构还在
  • 磁盘表是使用 InnoDB 引擎或者 MyISAM 引擎的临时表,建立 B+ 树索引,写数据的时候是写到磁盘上的

临时表的特点:

  • 一个临时表只能被创建它的 session 访问,对其他线程不可见,所以不同 session 的临时表是可以重名
  • 临时表可以与普通表同名,会话内有同名的临时表和普通表时,执行 show create 语句以及增删改查语句访问的都是临时表
  • show tables 命令不显示临时表
  • 数据库发生异常重启不需要担心数据删除问题,临时表会自动回收

重名原理

执行创建临时表的 SQL:

1
create temporary table temp_t(id int primary key)engine=innodb;

MySQL 给 InnoDB 表创建一个 frm 文件保存表结构定义,在 ibd 保存表数据。frm 文件放在临时文件目录下,文件名的后缀是 .frm,前缀是 #sql{进程 id}_{线程 id}_ 序列号,使用 select @@tmpdir 命令,来显示实例的临时文件目录

MySQL 维护数据表,除了物理磁盘上的文件外,内存里也有一套机制区别不同的表,每个表都对应一个 table_def_key

  • 一个普通表的 table_def_key 的值是由 库名 + 表名 得到的,所以如果在同一个库下创建两个同名的普通表,创建第二个表的过程中就会发现 table_def_key 已经存在了
  • 对于临时表,table_def_key 在 库名 + 表名 基础上,又加入了 server_id + thread_id,所以不同线程之间,临时表可以重名

实现原理:每个线程都维护了自己的临时表链表,每次 session 内操作表时,先遍历链表,检查是否有这个名字的临时表,如果有就优先操作临时表,如果没有再操作普通表;在 session 结束时对链表里的每个临时表,执行 DROP TEMPORARY TABLE + 表名 操作

执行 rename table 语句无法修改临时表,因为会按照 库名 / 表名.frm 的规则去磁盘找文件,但是临时表文件名的规则是 #sql{进程 id}_{线程 id}_ 序列号.frm,因此会报找不到文件名的错误


主备复制

创建临时表的语句会传到备库执行,因此备库的同步线程就会创建这个临时表。主库在线程退出时会自动删除临时表,但备库同步线程是持续在运行的并不会退出,所以这时就需要在主库上再写一个 DROP TEMPORARY TABLE 传给备库执行

binlog 日志写入规则:

  • binlog_format=row,跟临时表有关的语句就不会记录到 binlog
  • binlog_format=statment/mixed,binlog 中才会记录临时表的操作,也就会记录 DROP TEMPORARY TABLE 这条命令

主库上不同的线程创建同名的临时表是不冲突的,但是备库只有一个执行线程,所以 MySQL 在记录 binlog 时会把主库执行这个语句的线程 id 写到 binlog 中,在备库的应用线程就可以获取执行每个语句的主库线程 id,并利用这个线程 id 来构造临时表的 table_def_key

  • session A 的临时表 t1,在备库的 table_def_key 就是:库名 + t1 +“M 的 serverid" + "session A 的 thread_id”
  • session B 的临时表 t1,在备库的 table_def_key 就是 :库名 + t1 +"M 的 serverid" + "session B 的 thread_id"

MySQL 在记录 binlog 的时不论是 create table 还是 alter table 语句都是原样记录,但是如果执行 drop table,系统记录 binlog 就会被服务端改写

1
DROP TABLE `t_normal` /* generated by server */

跨库查询

分库分表系统的跨库查询使用临时表不用担心线程之间的重名冲突,分库分表就是要把一个逻辑上的大表分散到不同的数据库实例上

比如将一个大表 ht,按照字段 f,拆分成 1024 个分表,分布到 32 个数据库实例上,一般情况下都有一个中间层 proxy 解析 SQL 语句,通过分库规则通过分表规则(比如 N%1024)确定将这条语句路由到哪个分表做查询

1
select v from ht where f=N;

如果这个表上还有另外一个索引 k,并且查询语句:

1
select v from ht where k >= M order by t_modified desc limit 100;

查询条件里面没有用到分区字段 f,只能到所有的分区中去查找满足条件的所有行,然后统一做 order by 操作,两种方式:

  • 在 proxy 层的进程代码中实现排序,拿到分库的数据以后,直接在内存中参与计算,但是对 proxy 端的压力比较大,很容易出现内存不够用和 CPU 瓶颈问题
  • 把各个分库拿到的数据,汇总到一个 MySQL 实例的一个表中,然后在这个汇总实例上做逻辑操作,执行流程:
    • 在汇总库上创建一个临时表 temp_ht,表里包含三个字段 v、k、t_modified
    • 在各个分库执行:select v,k,t_modified from ht_x where k >= M order by t_modified desc limit 100
    • 把分库执行的结果插入到 temp_ht 表中
    • 在临时表上执行:select v from temp_ht order by t_modified desc limit 100

优化步骤

执行频率

MySQL 客户端连接成功后,查询服务器状态信息:

1
2
3
SHOW [SESSION|GLOBAL] STATUS LIKE '';
-- SESSION: 显示当前会话连接的统计结果,默认参数
-- GLOBAL: 显示自数据库上次启动至今的统计结果
  • 查看 SQL 执行频率:

    1
    SHOW STATUS LIKE 'Com_____';

    Com_xxx 表示每种语句执行的次数

  • 查询 SQL 语句影响的行数:

    1
    SHOW STATUS LIKE 'Innodb_rows_%';

Com_xxxx:这些参数对于所有存储引擎的表操作都会进行累计

Innodb_xxxx:这几个参数只是针对 InnoDB 存储引擎的,累加的算法也略有不同

参数 含义
Com_select 执行 SELECT 操作的次数,一次查询只累加 1
Com_insert 执行 INSERT 操作的次数,对于批量插入的 INSERT 操作,只累加一次
Com_update 执行 UPDATE 操作的次数
Com_delete 执行 DELETE 操作的次数
Innodb_rows_read 执行 SELECT 查询返回的行数
Innodb_rows_inserted 执行 INSERT 操作插入的行数
Innodb_rows_updated 执行 UPDATE 操作更新的行数
Innodb_rows_deleted 执行 DELETE 操作删除的行数
Connections 试图连接 MySQL 服务器的次数
Uptime 服务器工作时间
Slow_queries 慢查询的次数

定位低效

SQL 执行慢有两种情况:

  • 偶尔慢:DB 在刷新脏页(学完事务就懂了)
    • redo log 写满了
    • 内存不够用,要从 LRU 链表中淘汰
    • MySQL 认为系统空闲的时候
    • MySQL 关闭时
  • 一直慢的原因:索引没有设计好、SQL 语句没写好、MySQL 选错了索引

通过以下两种方式定位执行效率较低的 SQL 语句

  • 慢日志查询: 慢查询日志在查询结束以后才记录,执行效率出现问题时查询日志并不能定位问题

    配置文件修改:修改 .cnf 文件 vim /etc/mysql/my.cnf,重启 MySQL 服务器

    1
    2
    3
    4
    slow_query_log=ON
    slow_query_log_file=/usr/local/mysql/var/localhost-slow.log
    long_query_time=1 #记录超过long_query_time秒的SQL语句的日志
    log-queries-not-using-indexes = 1

    使用命令配置:

    1
    2
    mysql> SET slow_query_log=ON;
    mysql> SET GLOBAL slow_query_log=ON;

    查看是否配置成功:

    1
    SHOW VARIABLES LIKE '%query%'
  • SHOW PROCESSLIST:实时查看当前 MySQL 在进行的连接线程,包括线程的状态、是否锁表、SQL 的执行情况,同时对一些锁表操作进行优化


EXPLAIN

执行计划

通过 EXPLAIN 命令获取执行 SQL 语句的信息,包括在 SELECT 语句执行过程中如何连接和连接的顺序,执行计划在优化器优化完成后、执行器之前生成,然后执行器会调用存储引擎检索数据

查询 SQL 语句的执行计划:

1
EXPLAIN SELECT * FROM table_1 WHERE id = 1;

字段 含义
id SELECT 的序列号
select_type 表示 SELECT 的类型
table 访问数据库中表名称,有时可能是简称或者临时表名称(
type 表示表的连接类型
possible_keys 表示查询时,可能使用的索引
key 表示实际使用的索引
key_len 索引字段的长度
ref 表示与索引列进行等值匹配的对象,常数、某个列、函数等,type 必须在(range, const] 之间,左闭右开
rows 扫描出的行数,表示 MySQL 根据表统计信息及索引选用情况,估算的找到所需的记录扫描的行数
filtered 条件过滤的行百分比,单表查询没意义,用于连接查询中对驱动表的扇出进行过滤,查询优化器预测所有扇出值满足剩余查询条件的百分比,相乘以后表示多表查询中还要对被驱动执行查询的次数
extra 执行情况的说明和描述

MySQL 执行计划的局限

  • 只是计划,不是执行 SQL 语句,可以随着底层优化器输入的更改而更改
  • EXPLAIN 不会告诉显示关于触发器、存储过程的信息对查询的影响情况, 不考虑各种 Cache
  • EXPLAIN 不能显示 MySQL 在执行查询时的动态,因为执行计划在执行查询之前生成
  • EXPALIN 只能解释 SELECT 操作,其他操作要重写为 SELECT 后查看执行计划
  • EXPLAIN PLAN 显示的是在解释语句时数据库将如何运行 SQL 语句,由于执行环境和 EXPLAIN PLAN 环境的不同,此计划可能与 SQL 语句实际的执行计划不同,部分统计信息是估算的,并非精确值

SHOW WARINGS:在使用 EXPALIN 命令后执行该语句,可以查询与执行计划相关的拓展信息,展示出 Level、Code、Message 三个字段,当 Code 为 1003 时,Message 字段展示的信息类似于将查询语句重写后的信息,但是不是等价,不能执行复制过来运行

环境准备:


id

id 代表 SQL 执行的顺序的标识,每个 SELECT 关键字对应一个唯一 id,所以在同一个 SELECT 关键字中的表的 id 都是相同的。SELECT 后的 FROM 可以跟随多个表,每个表都会对应一条记录,这些记录的 id 都是相同的,

  • id 相同时,执行顺序由上至下。连接查询的执行计划,记录的 id 值都是相同的,出现在前面的表为驱动表,后面为被驱动表

    1
    EXPLAIN SELECT * FROM t_role r, t_user u, user_role ur WHERE r.id = ur.role_id AND u.id = ur.user_id ;

  • id 不同时,id 值越大优先级越高,越先被执行

    1
    EXPLAIN SELECT * FROM t_role WHERE id = (SELECT role_id FROM user_role WHERE user_id = (SELECT id FROM t_user WHERE username = 'stu1'))

  • id 有相同也有不同时,id 相同的可以认为是一组,从上往下顺序执行;在所有的组中,id 的值越大的组,优先级越高,越先执行

    1
    EXPLAIN SELECT * FROM t_role r , (SELECT * FROM user_role ur WHERE ur.`user_id` = '2') a WHERE r.id = a.role_id ; 

  • id 为 NULL 时代表的是临时表


select

表示查询中每个 select 子句的类型(简单 OR 复杂)

select_type 含义
SIMPLE 简单的 SELECT 查询,查询中不包含子查询或者 UNION
PRIMARY 查询中若包含任何复杂的子查询,最外层(也就是最左侧)查询标记为该标识
UNION 对于 UNION 或者 UNION ALL 的复杂查询,除了最左侧的查询,其余的小查询都是 UNION
UNION RESULT UNION 需要使用临时表进行去重,临时表的是 UNION RESULT
DEPENDENT UNION 对于 UNION 或者 UNION ALL 的复杂查询,如果各个小查询都依赖外层查询,是相关子查询,除了最左侧的小查询为 DEPENDENT SUBQUERY,其余都是 DEPENDENT UNION
SUBQUERY 子查询不是相关子查询,该子查询第一个 SELECT 代表的查询就是这种类型,会进行物化(该子查询只需要执行一次)
DEPENDENT SUBQUERY 子查询是相关子查询,该子查询第一个 SELECT 代表的查询就是这种类型,不会物化(该子查询需要执行多次)
DERIVED 在 FROM 列表中包含的子查询,被标记为 DERIVED(衍生),也就是生成物化派生表的这个子查询
MATERIALIZED 将子查询物化后与与外层进行连接查询,生成物化表的子查询

子查询为 DERIVED:SELECT * FROM (SELECT key1 FROM t1) AS derived_1 WHERE key1 > 10

子查询为 MATERIALIZED:SELECT * FROM t1 WHERE key1 IN (SELECT key1 FROM t2)


type

对表的访问方式,表示 MySQL 在表中找到所需行的方式,又称访问类型

type 含义
ALL 全表扫描,如果是 InnoDB 引擎是扫描聚簇索引
index 可以使用覆盖索引,但需要扫描全部索引
range 索引范围扫描,常见于 between、<、> 等的查询
index_subquery 子查询可以普通索引,则子查询的 type 为 index_subquery
unique_subquery 子查询可以使用主键或唯一二级索引,则子查询的 type 为 index_subquery
index_merge 索引合并
ref_or_null 非唯一性索引(普通二级索引)并且可以存储 NULL,进行等值匹配
ref 非唯一性索引与常量等值匹配
eq_ref 唯一性索引(主键或不存储 NULL 的唯一二级索引)进行等值匹配,如果二级索引是联合索引,那么所有联合的列都要进行等值匹配
const 通过主键或者唯一二级索引与常量进行等值匹配
system system 是 const 类型的特例,当查询的表只有一条记录的情况下,使用 system
NULL MySQL 在优化过程中分解语句,执行时甚至不用访问表或索引

从上到下,性能从差到好,一般来说需要保证查询至少达到 range 级别, 最好达到 ref


key

possible_keys:

  • 指出 MySQL 能使用哪个索引在表中找到记录,查询涉及到的字段上若存在索引,则该索引将被列出,但不一定被查询使用
  • 如果该列是 NULL,则没有相关的索引

key:

  • 显示 MySQL 在查询中实际使用的索引,若没有使用索引,显示为 NULL
  • 查询中若使用了覆盖索引,则该索引可能出现在 key 列表,不出现在 possible_keys

key_len:

  • 表示索引中使用的字节数,可通过该列计算查询中使用的索引的长度
  • key_len 显示的值为索引字段的最大可能长度,并非实际使用长度,即 key_len 是根据表定义计算而得,不是通过表内检索出的
  • 在不损失精确性的前提下,长度越短越好

Extra

其他的额外的执行计划信息,在该列展示:

  • No tables used:查询语句中使用 FROM dual 或者没有 FROM 语句
  • Impossible WHERE:查询语句中的 WHERE 子句条件永远为 FALSE,会导致没有符合条件的行
  • Using index:该值表示相应的 SELECT 操作中使用了覆盖索引(Covering Index)
  • Using index condition:第一种情况是搜索条件中虽然出现了索引列,但是部分条件无法形成扫描区间(索引失效),会根据可用索引的条件先搜索一遍再匹配无法使用索引的条件,回表查询数据;第二种是使用了索引条件下推优化
  • Using where:搜索的数据需要在 Server 层判断,无法使用索引下推
  • Using join buffer:连接查询被驱动表无法利用索引,需要连接缓冲区来存储中间结果
  • Using filesort:无法利用索引完成排序(优化方向),需要对数据使用外部排序算法,将取得的数据在内存或磁盘中进行排序
  • Using temporary:表示 MySQL 需要使用临时表来存储结果集,常见于排序、去重(UNION)、分组等场景
  • Select tables optimized away:说明仅通过使用索引,优化器可能仅从聚合函数结果中返回一行
  • No tables used:Query 语句中使用 from dual 或不含任何 from 子句

参考文章:https://www.cnblogs.com/ggjucheng/archive/2012/11/11/2765237.html


PROFILES

SHOW PROFILES 能够在做 SQL 优化时分析当前会话中语句执行的资源消耗情况

  • 通过 have_profiling 参数,能够看到当前 MySQL 是否支持 profile:

  • 默认 profiling 是关闭的,可以通过 set 语句在 Session 级别开启 profiling:

    1
    SET profiling=1; #开启profiling 开关;
  • 执行 SHOW PROFILES 指令, 来查看 SQL 语句执行的耗时:

    1
    SHOW PROFILES;

  • 查看到该 SQL 执行过程中每个线程的状态和消耗的时间:

    1
    SHOW PROFILE FOR QUERY query_id;

  • 在获取到最消耗时间的线程状态后,MySQL 支持选择 all、cpu、block io 、context switch、page faults 等类型查看 MySQL 在使用什么资源上耗费了过高的时间。例如,选择查看 CPU 的耗费时间:

    • Status:SQL 语句执行的状态
    • Durationsql:执行过程中每一个步骤的耗时
    • CPU_user:当前用户占有的 CPU
    • CPU_system:系统占有的 CPU

TRACE

MySQL 提供了对 SQL 的跟踪, 通过 trace 文件可以查看优化器生成执行计划的过程

  • 打开 trace 功能,设置格式为 JSON,并设置 trace 的最大使用内存,避免解析过程中因默认内存过小而不能够完整展示

    1
    2
    SET optimizer_trace="enabled=on",end_markers_in_json=ON;	-- 会话内有效
    SET optimizer_trace_max_mem_size=1000000;
  • 执行 SQL 语句:

    1
    SELECT * FROM tb_item WHERE id < 4;
  • 检查 information_schema.optimizer_trace:

    1
    SELECT * FROM information_schema.optimizer_trace \G; -- \G代表竖列展示

    执行信息主要有三个阶段:prepare 阶段、optimize 阶段(成本分析)、execute 阶段(执行)


索引优化

创建索引

索引是数据库优化最重要的手段之一,通过索引通常可以帮助用户解决大多数的 MySQL 的性能优化问题

1
2
3
4
5
6
7
8
9
10
11
12
CREATE TABLE `tb_seller` (
`sellerid` varchar (100),
`name` varchar (100),
`nickname` varchar (50),
`password` varchar (60),
`status` varchar (1),
`address` varchar (100),
`createtime` datetime,
PRIMARY KEY(`sellerid`)
)ENGINE=INNODB DEFAULT CHARSET=utf8mb4;
INSERT INTO `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('xiaomi','小米科技','小米官方旗舰店','e10adc3949ba59abbe56e057f20f883e','1','西安市','2088-01-01 12:00:00');
CREATE INDEX idx_seller_name_sta_addr ON tb_seller(name, status, address); # 联合索引


避免失效

语句错误
  • 全值匹配:对索引中所有列都指定具体值,这种情况索引生效,执行效率高

    1
    EXPLAIN SELECT * FROM tb_seller WHERE name='小米科技' AND status='1' AND address='西安市';

  • 最左前缀法则:联合索引遵守最左前缀法则

    匹配最左前缀法则,走索引:

    1
    2
    EXPLAIN SELECT * FROM tb_seller WHERE name='小米科技';
    EXPLAIN SELECT * FROM tb_seller WHERE name='小米科技' AND status='1';

    违法最左前缀法则 , 索引失效:

    1
    2
    EXPLAIN SELECT * FROM tb_seller WHERE status='1';
    EXPLAIN SELECT * FROM tb_seller WHERE status='1' AND address='西安市';

    如果符合最左法则,但是出现跳跃某一列,只有最左列索引生效:

    1
    EXPLAIN SELECT * FROM tb_seller WHERE name='小米科技' AND address='西安市';

    虽然索引列失效,但是系统会使用了索引下推进行了优化

  • 范围查询右边的列,不能使用索引:

    1
    EXPLAIN SELECT * FROM tb_seller WHERE name='小米科技' AND status>'1' AND address='西安市';

    根据前面的两个字段 name , status 查询是走索引的, 但是最后一个条件 address 没有用到索引,使用了索引下推

  • 在索引列上函数或者运算(+ - 数值)操作, 索引将失效:会破坏索引值的有序性

    1
    EXPLAIN SELECT * FROM tb_seller WHERE SUBSTRING(name,3,2) = '科技';

  • 字符串不加单引号,造成索引失效:隐式类型转换,当字符串和数字比较时会把字符串转化为数字

    在查询时,没有对字符串加单引号,查询优化器会调用 CAST 函数将 status 转换为 int 进行比较,造成索引失效

    1
    EXPLAIN SELECT * FROM tb_seller WHERE name='小米科技' AND status = 1;

    如果 status 是 int 类型,SQL 为 SELECT * FROM tb_seller WHERE status = '1' 并不会造成索引失效,因为会将 '1' 转换为 1,并不会对索引列产生操作

  • 多表连接查询时,如果两张表的字符集不同,会造成索引失效,因为会进行类型转换

    解决方法:CONVERT 函数是加在输入参数上、修改表的字符集

  • 用 OR 分割条件,索引失效,导致全表查询:

    OR 前的条件中的列有索引而后面的列中没有索引或 OR 前后两个列是同一个复合索引,都造成索引失效

    1
    2
    EXPLAIN SELECT * FROM tb_seller WHERE name='阿里巴巴' OR createtime = '2088-01-01 12:00:00';
    EXPLAIN SELECT * FROM tb_seller WHERE name='小米科技' OR status='1';

    AND 分割的条件不影响

    1
    EXPLAIN SELECT * FROM tb_seller WHERE name='阿里巴巴' AND createtime = '2088-01-01 12:00:00';

  • 以 % 开头的 LIKE 模糊查询,索引失效:

    如果是尾部模糊匹配,索引不会失效;如果是头部模糊匹配,索引失效

    1
    EXPLAIN SELECT * FROM tb_seller WHERE name like '%科技%';

    解决方案:通过覆盖索引来解决

    1
    EXPLAIN SELECT sellerid,name,status FROM tb_seller WHERE name like '%科技%';

    原因:在覆盖索引的这棵 B+ 数上只需要进行 like 的匹配,或者是基于覆盖索引查询再进行 WHERE 的判断就可以获得结果


系统优化

系统优化为全表扫描:

  • 如果 MySQL 评估使用索引比全表更慢,则不使用索引,索引失效:

    1
    2
    3
    CREATE INDEX idx_address ON tb_seller(address);
    EXPLAIN SELECT * FROM tb_seller WHERE address='西安市';
    EXPLAIN SELECT * FROM tb_seller WHERE address='北京市';

    北京市的键值占 9/10(区分度低),所以优化为全表扫描,type = ALL

  • IS NULL、IS NOT NULL 有时索引失效:

    1
    2
    EXPLAIN SELECT * FROM tb_seller WHERE name IS NULL;
    EXPLAIN SELECT * FROM tb_seller WHERE name IS NOT NULL;

    NOT NULL 失效的原因是 name 列全部不是 null,优化为全表扫描,当 NULL 过多时,IS NULL 失效

  • IN 肯定会走索引,但是当 IN 的取值范围较大时会导致索引失效,走全表扫描:

    1
    2
    EXPLAIN SELECT * FROM tb_seller WHERE sellerId IN ('alibaba','huawei');-- 都走索引
    EXPLAIN SELECT * FROM tb_seller WHERE sellerId NOT IN ('alibaba','huawei');
  • MySQL 实战 45 讲该章节最后提出了一种场景,获取到数据以后 Server 层还会做判断


底层原理

索引失效一般是针对联合索引,联合索引一般由几个字段组成,排序方式是先按照第一个字段进行排序,然后排序第二个,依此类推,图示(a, b)索引,a 相等的情况下 b 是有序的

  • 最左前缀法则:当不匹配前面的字段的时候,后面的字段都是无序的。这种无序不仅体现在叶子节点,也会导致查询时扫描的非叶子节点也是无序的,因为索引树相当于忽略的第一个字段,就无法使用二分查找

  • 范围查询右边的列,不能使用索引,比如语句: WHERE a > 1 AND b = 1 ,在 a 大于 1 的时候,b 是无序的,a > 1 是扫描时有序的,但是找到以后进行寻找 b 时,索引树就不是有序的了

  • 以 % 开头的 LIKE 模糊查询,索引失效,比如语句:WHERE a LIKE '%d',前面的不确定,导致不符合最左匹配,直接去索引中搜索以 d 结尾的节点,所以没有顺序

参考文章:https://mp.weixin.qq.com/s/B_M09dzLe9w7cT46rdGIeQ


查看索引

1
2
SHOW STATUS LIKE 'Handler_read%';	
SHOW GLOBAL STATUS LIKE 'Handler_read%';

  • Handler_read_first:索引中第一条被读的次数,如果较高,表示服务器正执行大量全索引扫描(这个值越低越好)

  • Handler_read_key:如果索引正在工作,这个值代表一个行被索引值读的次数,值越低表示索引不经常使用(这个值越高越好)

  • Handler_read_next:按照键顺序读下一行的请求数,如果范围约束或执行索引扫描来查询索引列,值增加

  • Handler_read_prev:按照键顺序读前一行的请求数,该读方法主要用于优化 ORDER BY … DESC

  • Handler_read_rnd:根据固定位置读一行的请求数,如果执行大量查询并对结果进行排序则该值较高,可能是使用了大量需要 MySQL 扫描整个表的查询或连接,这个值较高意味着运行效率低,应该建立索引来解决

  • Handler_read_rnd_next:在数据文件中读下一行的请求数,如果正进行大量的表扫描,该值较高,说明表索引不正确或写入的查询没有利用索引


SQL 优化

自增主键

自增机制

自增主键可以让主键索引尽量地保持递增顺序插入,避免了页分裂,因此索引更紧凑

表的结构定义存放在后缀名为.frm 的文件中,但是并不会保存自增值,不同的引擎对于自增值的保存策略不同:

  • MyISAM 引擎的自增值保存在数据文件中
  • InnoDB 引擎的自增值保存在了内存里,每次打开表都会去找自增值的最大值 max(id),然后将 max(id)+1 作为当前的自增值;8.0 版本后,才有了自增值持久化的能力,将自增值的变更记录在了 redo log 中,重启的时候依靠 redo log 恢复重启之前的值

在插入一行数据的时候,自增值的行为如下:

  • 如果插入数据时 id 字段指定为 0、null 或未指定值,那么就把这个表当前的 AUTO_INCREMENT 值填到自增字段
  • 如果插入数据时 id 字段指定了具体的值,比如某次要插入的值是 X,当前的自增值是 Y
    • 如果 X<Y,那么这个表的自增值不变
    • 如果 X≥Y,就需要把当前自增值修改为新的自增值

参数说明:auto_increment_offset 和 auto_increment_increment 分别表示自增的初始值和步长,默认值都是 1

语句执行失败也不回退自增 id,所以保证了自增 id 是递增的,但不保证是连续的(不能回退,所以有些回滚事务的自增 id 就不会重新使用,导致出现不连续)


自增 ID

MySQL 不同的自增 id 在达到上限后的表现不同:

  • 表的自增 id 如果是 int 类型,达到上限 2^32-1 后,再申请时值就不会改变,进而导致继续插入数据时报主键冲突的错误

  • row_id 长度为 6 个字节,达到上限后则会归 0 再重新递增,如果出现相同的 row_id,后写的数据会覆盖之前的数据,造成旧数据丢失,影响的是数据可靠性,所以应该在 InnoDB 表中主动创建自增主键报主键冲突,插入失败影响的是可用性,而一般情况下,可靠性优先于可用性

  • Xid 长度 8 字节,由 Server 层维护,只需要不在同一个 binlog 文件中出现重复值即可,虽然理论上会出现重复值,但是概率极小

  • InnoDB 的 max_trx_id 递增值每次 MySQL 重启都会被保存起来,重启也不会重置为 0,所以会导致一直增加到达上限,然后从 0 开始,这时原事务 0 修改的数据对当前事务就是可见的,产生脏读的现象

    只读事务不分配 trx_id,所以 trx_id 的增加速度变慢了

  • thread_id 长度 4 个字节,到达上限后就会重置为 0,MySQL 设计了一个唯一数组的逻辑,给新线程分配 thread_id 时做判断,保证不会出现两个相同的 thread_id:

    1
    2
    3
    do {
    new_id = thread_id_counter++;
    } while (!thread_ids.insert_unique(new_id).second);

参考文章:https://time.geekbang.org/column/article/83183


覆盖索引

复合索引叶子节点不仅保存了复合索引的值,还有主键索引,所以使用覆盖索引的时候,加上主键也会用到索引

尽量使用覆盖索引,避免 SELECT *:

1
EXPLAIN SELECT name,status,address FROM tb_seller WHERE name='小米科技' AND status='1' AND address='西安市';

如果查询列,超出索引列,也会降低性能:

1
EXPLAIN SELECT name,status,address,password FROM tb_seller WHERE name='小米科技' AND status='1' AND address='西安市';


减少访问

避免对数据进行重复检索:能够一次连接就获取到结果的,就不用两次连接,这样可以大大减少对数据库无用的重复请求

  • 查询数据:

    1
    2
    3
    4
    SELECT id,name FROM tb_book;
    SELECT id,status FROM tb_book; -- 向数据库提交两次请求,数据库就要做两次查询操作
    -- > 优化为:
    SELECT id,name,statu FROM tb_book;
  • 插入数据:

    1
    2
    3
    4
    5
    INSERT INTO tb_test VALUES(1,'Tom');
    INSERT INTO tb_test VALUES(2,'Cat');
    INSERT INTO tb_test VALUES(3,'Jerry'); -- 连接三次数据库
    -- >优化为
    INSERT INTO tb_test VALUES(1,'Tom'),(2,'Cat'),(3,'Jerry'); -- 连接一次
  • 在事务中进行数据插入:

    1
    2
    3
    4
    5
    start transaction;
    INSERT INTO tb_test VALUES(1,'Tom');
    INSERT INTO tb_test VALUES(2,'Cat');
    INSERT INTO tb_test VALUES(3,'Jerry');
    commit; -- 手动提交,分段提交
  • 数据有序插入:

    1
    2
    3
    INSERT INTO tb_test VALUES(1,'Tom');
    INSERT INTO tb_test VALUES(2,'Cat');
    INSERT INTO tb_test VALUES(3,'Jerry');

增加 cache 层:在应用中增加缓存层来达到减轻数据库负担的目的。可以部分数据从数据库中抽取出来放到应用端以文本方式存储,或者使用框架(Mybatis)提供的一级缓存 / 二级缓存,或者使用 Redis 数据库来缓存数据


数据插入

当使用 load 命令导入数据的时候,适当的设置可以提高导入的效率:

![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL load data.png)

1
LOAD DATA LOCAL INFILE = '/home/seazean/sql1.log' INTO TABLE `tb_user_1` FIELD TERMINATED BY ',' LINES TERMINATED BY '\n'; -- 文件格式如上图

对于 InnoDB 类型的表,有以下几种方式可以提高导入的效率:

  1. 主键顺序插入:因为 InnoDB 类型的表是按照主键的顺序保存的,所以将导入的数据按照主键的顺序排列,可以有效的提高导入数据的效率,如果 InnoDB 表没有主键,那么系统会自动默认创建一个内部列作为主键

    主键是否连续对性能影响不大,只要是递增的就可以,比如雪花算法产生的 ID 不是连续的,但是是递增的,因为递增可以让主键索引尽量地保持顺序插入,避免了页分裂,因此索引更紧凑

    • 插入 ID 顺序排列数据:

    • 插入 ID 无序排列数据:

  2. 关闭唯一性校验:在导入数据前执行 SET UNIQUE_CHECKS=0,关闭唯一性校验;导入结束后执行 SET UNIQUE_CHECKS=1,恢复唯一性校验,可以提高导入的效率。

  3. 手动提交事务:如果应用使用自动提交的方式,建议在导入前执行SET AUTOCOMMIT=0,关闭自动提交;导入结束后再打开自动提交,可以提高导入的效率。

    事务需要控制大小,事务太大可能会影响执行的效率。MySQL 有 innodb_log_buffer_size 配置项,超过这个值的日志会写入磁盘数据,效率会下降,所以在事务大小达到配置项数据级前进行事务提交可以提高效率


分组排序

ORDER

数据准备:

1
2
3
4
5
6
7
8
9
CREATE TABLE `emp` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`name` VARCHAR(100) NOT NULL,
`age` INT(3) NOT NULL,
`salary` INT(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=INNODB DEFAULT CHARSET=utf8mb4;
INSERT INTO `emp` (`id`, `name`, `age`, `salary`) VALUES('1','Tom','25','2300');-- ...
CREATE INDEX idx_emp_age_salary ON emp(age, salary);
  • 第一种是通过对返回数据进行排序,所有不通过索引直接返回结果的排序都叫 FileSort 排序,会在内存中重新排序

    1
    EXPLAIN SELECT * FROM emp ORDER BY age DESC;	-- 年龄降序

    ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL ORDER BY排序1.png)

  • 第二种通过有序索引顺序扫描直接返回有序数据,这种情况为 Using index,不需要额外排序,操作效率高

    1
    EXPLAIN SELECT id, age, salary FROM emp ORDER BY age DESC;

    ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL ORDER BY排序2.png)

  • 多字段排序:

    1
    2
    3
    EXPLAIN SELECT id,age,salary FROM emp ORDER BY age DESC, salary DESC;
    EXPLAIN SELECT id,age,salary FROM emp ORDER BY salary DESC, age DESC;
    EXPLAIN SELECT id,age,salary FROM emp ORDER BY age DESC, salary ASC;

    ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL ORDER BY排序3.png)

    尽量减少额外的排序,通过索引直接返回有序数据。需要满足 Order by 使用相同的索引、Order By 的顺序和索引顺序相同、Order by 的字段都是升序或都是降序,否则需要额外的操作,就会出现 FileSort

  • ORDER BY RAND() 命令用来进行随机排序,会使用了临时内存表,临时内存表排序的时使用 rowid 排序方法

优化方式:创建合适的索引能够减少 Filesort 的出现,但是某些情况下条件限制不能让 Filesort 消失,就要加快 Filesort 的排序操作

内存临时表,MySQL 有两种 Filesort 排序算法:

  • rowid 排序:首先根据条件取出排序字段和信息,然后在排序区 sort buffer(Server 层)中排序,如果 sort buffer 不够,则在临时表 temporary table 中存储排序结果。完成排序后再根据行指针回表读取记录,该操作可能会导致大量随机 I/O 操作

    说明:对于临时内存表,回表过程只是简单地根据数据行的位置,直接访问内存得到数据,不会导致多访问磁盘,优先选择该方式

  • 全字段排序:一次性取出满足条件的所有数据,需要回表,然后在排序区 sort buffer 中排序后直接输出结果集。排序时内存开销较大,但是排序效率比两次扫描算法高

具体的选择方式:

  • MySQL 通过比较系统变量 max_length_for_sort_data 的大小和 Query 语句取出的字段的大小,来判定使用哪种排序算法。如果前者大,则说明 sort buffer 空间足够,使用第二种优化之后的算法,否则使用第一种。

  • 可以适当提高 sort_buffer_size 和 max_length_for_sort_data 系统变量,来增大排序区的大小,提高排序的效率

    1
    2
    3
    4
    SET @@max_length_for_sort_data = 10000; 		-- 设置全局变量
    SET max_length_for_sort_data = 10240; -- 设置会话变量
    SHOW VARIABLES LIKE 'max_length_for_sort_data'; -- 默认1024
    SHOW VARIABLES LIKE 'sort_buffer_size'; -- 默认262114

磁盘临时表:排序使用优先队列(堆)的方式


GROUP

GROUP BY 也会进行排序操作,与 ORDER BY 相比,GROUP BY 主要只是多了排序之后的分组操作,所以在 GROUP BY 的实现过程中,与 ORDER BY 一样也可以利用到索引


联合查询

对于包含 OR 的查询子句,如果要利用索引,则 OR 之间的每个条件列都必须用到索引,而且不能使用到条件之间的复合索引,如果没有索引,则应该考虑增加索引

  • 执行查询语句:

    1
    EXPLAIN SELECT * FROM emp WHERE id = 1 OR age = 30;	-- 两个索引,并且不是复合索引

    ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL OR条件查询1.png)

    1
    Extra: Using sort_union(idx_emp_age_salary,PRIMARY); Using where
  • 使用 UNION 替换 OR,求并集:

    注意:该优化只针对多个索引列有效,如果有列没有被索引,查询效率可能会因为没有选择 OR 而降低

    1
    EXPLAIN SELECT * FROM emp WHERE id = 1 UNION SELECT * FROM emp WHERE age = 30;

    ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-优化SQL OR条件查询2.png)

  • UNION 要优于 OR 的原因:

    • UNION 语句的 type 值为 ref,OR 语句的 type 值为 range
    • UNION 语句的 ref 值为 const,OR 语句的 ref 值为 null,const 表示是常量值引用,非常快

嵌套查询

MySQL 4.1 版本之后,开始支持 SQL 的子查询

  • 可以使用 SELECT 语句来创建一个单列的查询结果,然后把结果作为过滤条件用在另一个查询中
  • 使用子查询可以一次性的完成逻辑上需要多个步骤才能完成的 SQL 操作,同时也可以避免事务或者表锁死
  • 在有些情况下,子查询是可以被更高效的连接(JOIN)替代

例如查找有角色的所有的用户信息:

  • 执行计划:

    1
    EXPLAIN SELECT * FROM t_user WHERE id IN (SELECT user_id FROM user_role);

  • 优化后:

    1
    EXPLAIN SELECT * FROM t_user u , user_role ur WHERE u.id = ur.user_id;

    连接查询之所以效率更高 ,是因为不需要在内存中创建临时表来完成逻辑上需要两个步骤的查询工作


分页查询

一般分页查询时,通过创建覆盖索引能够比较好地提高性能

一个常见的问题是 LIMIT 200000,10,此时需要 MySQL 扫描前 200010 记录,仅仅返回 200000 - 200010 之间的记录,其他记录丢弃,查询排序的代价非常大

  • 分页查询:

    1
    EXPLAIN SELECT * FROM tb_user_1 LIMIT 200000,10;

  • 优化方式一:内连接查询,在索引列 id 上完成排序分页操作,最后根据主键关联回原表查询所需要的其他列内容

    1
    EXPLAIN SELECT * FROM tb_user_1 t,(SELECT id FROM tb_user_1 ORDER BY id LIMIT 200000,10) a WHERE t.id = a.id;

  • 优化方式二:方案适用于主键自增的表,可以把 LIMIT 查询转换成某个位置的查询

    1
    2
    EXPLAIN SELECT * FROM tb_user_1 WHERE id > 200000 LIMIT 10;			-- 写法 1
    EXPLAIN SELECT * FROM tb_user_1 WHERE id BETWEEN 200000 and 200010; -- 写法 2


使用提示

SQL 提示,是优化数据库的一个重要手段,就是在 SQL 语句中加入一些提示来达到优化操作的目的

  • USE INDEX:在查询语句中表名的后面添加 USE INDEX 来提供 MySQL 去参考的索引列表,可以让 MySQL 不再考虑其他可用的索引

    1
    2
    CREATE INDEX idx_seller_name ON tb_seller(name);
    EXPLAIN SELECT * FROM tb_seller USE INDEX(idx_seller_name) WHERE name='小米科技';

  • IGNORE INDEX:让 MySQL 忽略一个或者多个索引,则可以使用 IGNORE INDEX 作为提示

    1
    EXPLAIN SELECT * FROM tb_seller IGNORE INDEX(idx_seller_name) WHERE name = '小米科技';

  • FORCE INDEX:强制 MySQL 使用一个特定的索引

    1
    EXPLAIN SELECT * FROM tb_seller FORCE INDEX(idx_seller_name_sta_addr) WHERE NAME='小米科技';


统计计数

在不同的 MySQL 引擎中,count(*) 有不同的实现方式:

  • MyISAM 引擎把一个表的总行数存在了磁盘上,因此执行 count(*) 的时候会直接返回这个数,效率很高,但不支持事务
  • show table status 命令通过采样估算可以快速获取,但是不准确
  • InnoDB 表执行 count(*) 会遍历全表,虽然结果准确,但会导致性能问题

解决方案:

  • 计数保存在 Redis 中,但是更新 MySQL 和 Redis 的操作不是原子的,会存在数据一致性的问题

  • 计数直接放到数据库里单独的一张计数表中,利用事务解决计数精确问题:

    会话 B 的读操作在 T3 执行的,这时更新事务还没有提交,所以计数值加 1 这个操作对会话 B 还不可见,因此会话 B 查询的计数值和最近 100 条记录,返回的结果逻辑上就是一致的

    并发系统性能的角度考虑,应该先插入操作记录再更新计数表,因为更新计数表涉及到行锁的竞争,先插入再更新能最大程度地减少事务之间的锁等待,提升并发度

count 函数的按照效率排序:count(字段) < count(主键id) < count(1) ≈ count(*),所以建议尽量使用 count(*)

  • count(主键 id):InnoDB 引擎会遍历整张表,把每一行的 id 值都取出来返回给 Server 层,Server 判断 id 不为空就按行累加
  • count(1):InnoDB 引擎遍历整张表但不取值,Server 层对于返回的每一行,放一个数字 1 进去,判断不为空就按行累加
  • count(字段):如果这个字段是定义为 not null 的话,一行行地从记录里面读出这个字段,判断不能为 null,按行累加;如果这个字段定义允许为 null,那么执行的时候,判断到有可能是 null,还要把值取出来再判断一下,不是 null 才累加
  • count(*):不取值,按行累加

参考文章:https://time.geekbang.org/column/article/72775


缓冲优化

优化原则

三个原则:

  • 将尽量多的内存分配给 MySQL 做缓存,但也要给操作系统和其他程序预留足够内存
  • MyISAM 存储引擎的数据文件读取依赖于操作系统自身的 IO 缓存,如果有 MyISAM 表,就要预留更多的内存给操作系统做 IO 缓存
  • 排序区、连接区等缓存是分配给每个数据库会话(Session)专用的,值的设置要根据最大连接数合理分配,如果设置太大,不但浪费资源,而且在并发数较高时会导致物理内存耗尽

缓冲内存

Buffer Pool 本质上是 InnoDB 向操作系统申请的一段连续的内存空间。InnoDB 的数据是按数据页为单位来读写,每个数据页的大小默认是 16KB。数据是存放在磁盘中,每次读写数据都需要进行磁盘 IO 将数据读入内存进行操作,效率会很低,所以提供了 Buffer Pool 来暂存这些数据页,缓存中的这些页又叫缓冲页

工作原理:

  • 从数据库读取数据时,会首先从缓存中读取,如果缓存中没有,则从磁盘读取后放入 Buffer Pool
  • 向数据库写入数据时,会写入缓存,缓存中修改的数据会定期刷新到磁盘,这一过程称为刷脏

Buffer Pool 中每个缓冲页都有对应的控制信息,包括表空间编号、页号、偏移量、链表信息等,控制信息存放在占用的内存称为控制块,控制块与缓冲页是一一对应的,但并不是物理上相连的,都在缓冲池中

MySQL 提供了缓冲页的快速查找方式:哈希表,使用表空间号和页号作为 Key,缓冲页控制块的地址作为 Value 创建一个哈希表,获取数据页时根据 Key 进行哈希寻址:

  • 如果不存在对应的缓存页,就从 free 链表中选一个空闲缓冲页,把磁盘中的对应页加载到该位置
  • 如果存在对应的缓存页,直接获取使用,提高查询数据的效率

当内存数据页跟磁盘数据页内容不一致时,称这个内存页为脏页;内存数据写入磁盘后,内存和磁盘上的数据页一致,称为干净页


内存管理

Free 链表

MySQL 启动时完成对 Buffer Pool 的初始化,先向操作系统申请连续的内存空间,然后将内存划分为若干对控制块和缓冲页。为了区分空闲和已占用的数据页,将所有空闲缓冲页对应的控制块作为一个节点放入一个链表中,就是 Free 链表(空闲链表

基节点:是一块单独申请的内存空间(占 40 字节),并不在 Buffer Pool 的那一大片连续内存空间里

磁盘加载页的流程:

  • 从 Free 链表中取出一个空闲的缓冲页
  • 把缓冲页对应的控制块的信息填上(页所在的表空间、页号之类的信息)
  • 把缓冲页对应的 Free 链表节点(控制块)从链表中移除,表示该缓冲页已经被使用

参考文章:https://blog.csdn.net/li1325169021/article/details/121124440


Flush 链表

Flush 链表是一个用来存储脏页的链表,对于已经修改过的缓冲脏页,第一次修改后加入到链表头部,以后每次修改都不会重新加入,只修改部分控制信息,出于性能考虑并不是直接更新到磁盘,而是在未来的某个时间进行刷脏

后台有专门的线程每隔一段时间把脏页刷新到磁盘

  • 从 Flush 链表中刷新一部分页面到磁盘:
    • 后台线程定时从 Flush 链表刷脏,根据系统的繁忙程度来决定刷新速率,这种方式称为 BUF_FLUSH_LIST
    • 线程刷脏的比较慢,导致用户线程加载一个新的数据页时发现没有空闲缓冲页,此时会尝试从 LRU 链表尾部寻找缓冲页直接释放,如果该页面是已经修改过的脏页就同步刷新到磁盘,速度较慢,这种方式称为 BUF_FLUSH_SINGLE_PAGE
  • 从 LRU 链表的冷数据中刷新一部分页面到磁盘,即:BUF_FLUSH_LRU
    • 后台线程会定时从 LRU 链表的尾部开始扫描一些页面,扫描的页面数量可以通过系统变量 innodb_lru_scan_depth 指定,如果在 LRU 链表中发现脏页,则把它们刷新到磁盘,这种方式称为 BUF_FLUSH_LRU
    • 控制块里会存储该缓冲页是否被修改的信息,所以可以很容易的获取到某个缓冲页是否是脏页

参考文章:https://blog.csdn.net/li1325169021/article/details/121125765


LRU 链表

当 Buffer Pool 中没有空闲缓冲页时就需要淘汰掉最近最少使用的部分缓冲页,为了实现这个功能,MySQL 创建了一个 LRU 链表,当访问某个页时:

  • 如果该页不在 Buffer Pool 中,把该页从磁盘加载进来后会将该缓冲页对应的控制块作为节点放入 LRU 链表的头部
  • 如果该页在 Buffer Pool 中,则直接把该页对应的控制块移动到 LRU 链表的头部,所以 LRU 链表尾部就是最近最少使用的缓冲页

MySQL 基于局部性原理提供了预读功能:

  • 线性预读:系统变量 innodb_read_ahead_threshold,如果顺序访问某个区(extent:16 KB 的页,连续 64 个形成一个区,一个区默认 1MB 大小)的页面数超过了该系统变量值,就会触发一次异步读取下一个区中全部的页面到 Buffer Pool 中
  • 随机预读:如果某个区 13 个连续的页面都被加载到 Buffer Pool,无论这些页面是否是顺序读取,都会触发一次异步读取本区所有的其他页面到 Buffer Pool 中

预读会造成加载太多用不到的数据页,造成那些使用频率很高的数据页被挤到 LRU 链表尾部,所以 InnoDB 将 LRU 链表分成两段:

  • 一部分存储使用频率很高的数据页,这部分链表也叫热数据,young 区,靠近链表头部的区域
  • 一部分存储使用频率不高的冷数据,old 区,靠近链表尾部,默认占 37%,可以通过系统变量 innodb_old_blocks_pct 指定

当磁盘上的某数据页被初次加载到 Buffer Pool 中会被放入 old 区,淘汰时优先淘汰 old 区

  • 当对 old 区的数据进行访问时,会在控制块记录下访问时间,等待后续的访问时间与第一次访问的时间是否在某个时间间隔内,通过系统变量 innodb_old_blocks_time 指定时间间隔,默认 1000ms,成立就移动到 young 区的链表头部
  • innodb_old_blocks_time 为 0 时,每次访问一个页面都会放入 young 区的头部

参数优化

InnoDB 用一块内存区做 IO 缓存池,该缓存池不仅用来缓存 InnoDB 的索引块,也用来缓存 InnoDB 的数据块,可以通过下面的指令查看 Buffer Pool 的状态信息:

1
SHOW ENGINE INNODB STATUS\G

Buffer pool hit rate 字段代表内存命中率,表示 Buffer Pool 对查询的加速效果

核心参数:

  • innodb_buffer_pool_size:该变量决定了 Innodb 存储引擎表数据和索引数据的最大缓存区大小,默认 128M

    1
    SHOW VARIABLES LIKE 'innodb_buffer_pool_size';

    在保证操作系统及其他程序有足够内存可用的情况下,innodb_buffer_pool_size 的值越大,缓存命中率越高,建议设置成可用物理内存的 60%~80%

    1
    innodb_buffer_pool_size=512M
  • innodb_log_buffer_size:该值决定了 Innodb 日志缓冲区的大小,保存要写入磁盘上的日志文件数据

    对于可能产生大量更新记录的大事务,增加该值的大小,可以避免 Innodb 在事务提交前就执行不必要的日志写入磁盘操作,影响执行效率,通过配置文件修改:

    1
    innodb_log_buffer_size=10M

在多线程下,访问 Buffer Pool 中的各种链表都需要加锁,所以将 Buffer Pool 拆成若干个小实例,每个线程对应一个实例,独立管理内存空间和各种链表(类似 ThreadLocal),多线程访问各自实例互不影响,提高了并发能力

MySQL 5.7.5 之前 innodb_buffer_pool_size 只支持在系统启动时修改,现在已经支持运行时修改 Buffer Pool 的大小,但是每次调整参数都会重新向操作系统申请一块连续的内存空间,将旧的缓冲池的内容拷贝到新空间非常耗时,所以 MySQL 开始以一个 chunk 为单位向操作系统申请内存,所以一个 Buffer Pool 实例可以由多个 chunk 组成

  • 在系统启动时设置系统变量 innodb_buffer_pool_instance 可以指定 Buffer Pool 实例的个数,但是当 Buffer Pool 小于 1GB 时,设置多个实例时无效的
  • 指定系统变量 innodb_buffer_pool_chunk_size 来改变 chunk 的大小,只能在启动时修改,运行中不能修改,而且该变量并不包含缓冲页的控制块的内存大小
  • innodb_buffer_pool_size 必须是 innodb_buffer_pool_chunk_size × innodb_buffer_pool_instance 的倍数,默认值是 128M × 16 = 2G,Buffer Pool 必须是 2G 的整数倍,如果指定 5G,会自动调整成 6G
  • 如果启动时 chunk × instances > pool_size,那么 chunk 的值会自动设置为 pool_size ÷ instances

内存优化

Change

InnoDB 管理的 Buffer Pool 中有一块内存叫 Change Buffer 用来对增删改操作提供缓存,参数 innodb_change_buffer_max_size 来动态设置,设置为 50 时表示 Change Buffer 的大小最多只能占用 Buffer Pool 的 50%

  • 唯一索引的更新不能使用 Change Buffer,需要将数据页读入内存,判断没有冲突在写入
  • 普通索引可以使用 Change Buffer,直接写入 Buffer 就结束,不用校验唯一性

Change Buffer 并不是数据页,只是对操作的缓存,所以需要将 Change Buffer 中的操作应用到旧数据页,得到新的数据页(脏页)的过程称为 Merge

  • 触发时机:访问数据页时会触发 Merge、后台有定时线程进行 Merge、在数据库正常关闭(shutdown)的过程中也会触发
  • 工作流程:首先从磁盘读入数据页到内存(因为 Buffer Pool 中不一定存在对应的数据页),从 Change Buffer 中找到对应的操作应用到数据页,得到新的数据页即为脏页,然后写入 redo log,等待刷脏即可

说明:Change Buffer 中的记录,在事务提交时也会写入 redo log,所以是可以保证不丢失的

业务场景:

  • 对于写多读少的业务来说,页面在写完以后马上被访问到的概率比较小,此时 Change Buffer 的使用效果最好,常见的就是账单类、日志类的系统

  • 一个业务的更新模式是写入后马上做查询,那么即使满足了条件,将更新先记录在 Change Buffer,但之后由于马上要访问这个数据页,会立即触发 Merge 过程,这样随机访问 IO 的次数不会减少,并且增加了 Change Buffer 的维护代价

补充:Change Buffer 的前身是 Insert Buffer,只能对 Insert 操作优化,后来增加了 Update/Delete 的支持,改为 Change Buffer


Net

Server 层针对优化查询的内存为 Net Buffer,内存的大小是由参数 net_buffer_length定义,默认 16k,实现流程:

  • 获取一行数据写入 Net Buffer,重复获取直到 Net Buffer 写满,调用网络接口发出去
  • 若发送成功就清空 Net Buffer,然后继续取下一行;若发送函数返回 EAGAIN 或 WSAEWOULDBLOCK,表示本地网络栈 socket send buffer 写满了,进入等待,直到网络栈重新可写再继续发送

MySQL 采用的是边读边发的逻辑,因此对于数据量很大的查询来说,不会在 Server 端保存完整的结果集,如果客户端读结果不及时,会堵住 MySQL 的查询过程,但是不会把内存打爆导致 OOM

SHOW PROCESSLIST 获取线程信息后,处于 Sending to client 状态代表服务器端的网络栈写满,等待客户端接收数据

假设有一个业务的逻辑比较复杂,每读一行数据以后要处理很久的逻辑,就会导致客户端要过很久才会去取下一行数据,导致 MySQL 的阻塞,一直处于 Sending to client 的状态

解决方法:如果一个查询的返回结果很是很多,建议使用 mysql_store_result 这个接口,直接把查询结果保存到本地内存

参考文章:https://blog.csdn.net/qq_33589510/article/details/117673449


Read

read_rnd_buffer 是 MySQL 的随机读缓冲区,当按任意顺序读取记录行时将分配一个随机读取缓冲区,进行排序查询时,MySQL 会首先扫描一遍该缓冲,以避免磁盘搜索,提高查询速度,大小是由 read_rnd_buffer_size 参数控制的

Multi-Range Read 优化,将随机 IO 转化为顺序 IO 以降低查询过程中 IO 开销,因为大多数的数据都是按照主键递增顺序插入得到,所以按照主键的递增顺序查询的话,对磁盘的读比较接近顺序读,能够提升读性能

二级索引为 a,聚簇索引为 id,优化回表流程:

  • 根据索引 a,定位到满足条件的记录,将 id 值放入 read_rnd_buffer 中
  • 将 read_rnd_buffer 中的 id 进行递增排序
  • 排序后的 id 数组,依次回表到主键 id 索引中查记录,并作为结果返回

说明:如果步骤 1 中 read_rnd_buffer 放满了,就会先执行步骤 2 和 3,然后清空 read_rnd_buffer,之后继续找索引 a 的下个记录

使用 MRR 优化需要设进行设置:

1
SET optimizer_switch='mrr_cost_based=off'

Key

MyISAM 存储引擎使用 key_buffer 缓存索引块,加速 MyISAM 索引的读写速度。对于 MyISAM 表的数据块没有特别的缓存机制,完全依赖于操作系统的 IO 缓存

  • key_buffer_size:该变量决定 MyISAM 索引块缓存区的大小,直接影响到 MyISAM 表的存取效率

    1
    SHOW VARIABLES LIKE 'key_buffer_size';	-- 单位是字节

    在 MySQL 配置文件中设置该值,建议至少将1/4可用内存分配给 key_buffer_size:

    1
    2
    vim /etc/mysql/my.cnf
    key_buffer_size=1024M
  • read_buffer_size:如果需要经常顺序扫描 MyISAM 表,可以通过增大 read_buffer_size 的值来改善性能。但 read_buffer_size 是每个 Session 独占的,如果默认值设置太大,并发环境就会造成内存浪费

  • read_rnd_buffer_size:对于需要做排序的 MyISAM 表的查询,如带有 ORDER BY 子句的语句,适当增加该的值,可以改善此类的 SQL 的性能,但是 read_rnd_buffer_size 是每个 Session 独占的,如果默认值设置太大,就会造成内存浪费


存储优化

数据存储

系统表空间是用来放系统信息的,比如数据字典什么的,对应的磁盘文件是 ibdata,数据表空间是一个个的表数据文件,对应的磁盘文件就是表名.ibd

表数据既可以存在共享表空间里,也可以是单独的文件,这个行为是由参数 innodb_file_per_table 控制的:

  • OFF:表示表的数据放在系统共享表空间,也就是跟数据字典放在一起
  • ON :表示每个 InnoDB 表数据存储在一个以 .ibd 为后缀的文件中(默认)

一个表单独存储为一个文件更容易管理,在不需要这个表时通过 drop table 命令,系统就会直接删除这个文件;如果是放在共享表空间中,即使表删掉了,空间也是不会回收的


数据删除

MySQL 的数据删除就是移除掉某个记录后,该位置就被标记为可复用,如果有符合范围条件的数据可以插入到这里。符合范围条件的意思是假设删除记录 R4,之后要再插入一个 ID 在 300 和 600 之间的记录时,就会复用这个位置

InnoDB 的数据是按页存储的如果删掉了一个数据页上的所有记录,整个数据页就可以被复用了,如果相邻的两个数据页利用率都很小,系统就会把这两个页上的数据合到其中一个页上,另外一个数据页就被标记为可复用

删除命令其实只是把记录的位置,或者数据页标记为了可复用,但磁盘文件的大小是不会变的,这些可以复用还没有被使用的空间,看起来就像是空洞,造成数据库的稀疏,因此需要进行紧凑处理


重建数据

重建表就是按照主键 ID 递增的顺序,把数据一行一行地从旧表中读出来再插入到新表中,让数据更加紧凑。重建表时 MySQL 会自动完成转存数据、交换表名、删除旧表的操作,线上操作会阻塞大量的线程增删改查的操作

重建命令:

1
ALTER TABLE A ENGINE=InnoDB

工作流程:新建临时表 tmp_table B(在 Server 层创建的),把表 A 中的数据导入到表 B 中,操作完成后用表 B 替换表 A,完成重建

重建表的步骤需要 DDL 不是 Online 的,因为在导入数据的过程有新的数据要写入到表 A 的话,就会造成数据丢失

MySQL 5.6 版本开始引入的 Online DDL,重建表的命令默认执行此步骤:

  • 建立一个临时文件 tmp_file(InnoDB 创建),扫描表 A 主键的所有数据页
  • 用数据页中表 A 的记录生成 B+ 树,存储到临时文件中
  • 生成临时文件的过程中,将所有对 A 的操作记录在一个日志文件(row log)中,对应的是图中 state2 的状态
  • 临时文件生成后,将日志文件中的操作应用到临时文件,得到一个逻辑数据上与表 A 相同的数据文件,对应的就是图中 state3
  • 用临时文件替换表 A 的数据文件

Online DDL 操作会先获取 MDL 写锁,再退化成 MDL 读锁。但 MDL 写锁持有时间比较短,所以可以称为 Online; 而 MDL 读锁,不阻止数据增删查改,但会阻止其它线程修改表结构(可以对比 ANALYZE TABLE t 命令)

问题:重建表可以收缩表空间,但是执行指令后整体占用空间增大

原因:在重建表后 InnoDB 不会把整张表占满,每个页留了 1/16 给后续的更新使用。表在未整理之前页已经占用 15/16 以上,收缩之后需要保持数据占用空间在 15/16,所以文件占用空间更大才能保持

注意:临时文件也要占用空间,如果空间不足会重建失败


原地置换

DDL 中的临时表 tmp_table 是在 Server 层创建的,Online DDL 中的临时文件 tmp_file 是 InnoDB 在内部创建出来的,整个 DDL 过程都在 InnoDB 内部完成,对于 Server 层来说,没有把数据挪动到临时表,是一个原地操作,这就是 inplace

两者的关系:

  • DDL 过程如果是 Online 的,就一定是 inplace 的
  • inplace 的 DDL,有可能不是 Online 的,截止到 MySQL 8.0,全文索引(FULLTEXT)和空间索引(SPATIAL)属于这种情况

并发优化

MySQL Server 是多线程结构,包括后台线程和客户服务线程。多线程可以有效利用服务器资源,提高数据库的并发性能。在 MySQL 中,控制并发连接和线程的主要参数:

  • max_connections:控制允许连接到 MySQL 数据库的最大连接数,默认值是 151

    如果状态变量 connection_errors_max_connections 不为零,并且一直增长,则说明不断有连接请求因数据库连接数已达到允许最大值而失败,这时可以考虑增大 max_connections 的值

    MySQL 最大可支持的连接数取决于很多因素,包括操作系统平台的线程库的质量、内存大小、每个连接的负荷、CPU的处理速度、期望的响应时间等。在 Linux 平台下,性能好的服务器,可以支持 500-1000 个连接,需要根据服务器性能进行评估设定

  • innodb_thread_concurrency:并发线程数,代表系统内同时运行的线程数量(已经被移除)

  • back_log:控制 MySQL 监听 TCP 端口时的积压请求栈的大小

    如果 Mysql 的连接数达到 max_connections 时,新来的请求将会被存在堆栈中,以等待某一连接释放资源,该堆栈的数量即 back_log。如果等待连接的数量超过 back_log,将不被授予连接资源直接报错

    5.6.6 版本之前默认值为 50,之后的版本默认为 50 + (max_connections/5),但最大不超过900,如果需要数据库在较短的时间内处理大量连接请求, 可以考虑适当增大 back_log 的值

  • table_open_cache:控制所有 SQL 语句执行线程可打开表缓存的数量

    在执行 SQL 语句时,每个执行线程至少要打开1个表缓存,该参数的值应该根据设置的最大连接数以及每个连接执行关联查询中涉及的表的最大数量来设定:max_connections * N

  • thread_cache_size:可控制 MySQL 缓存客户服务线程的数量

    为了加快连接数据库的速度,MySQL 会缓存一定数量的客户服务线程以备重用,池化思想

  • innodb_lock_wait_timeout:设置 InnoDB 事务等待行锁的时间,默认值是 50ms

    对于需要快速反馈的业务系统,可以将行锁的等待时间调小,以避免事务被长时间挂起; 对于后台运行的批量处理程序来说,可以将行锁的等待时间调大,以避免发生大的回滚操作


事务机制

基本介绍

事务(Transaction)是访问和更新数据库的程序执行单元;事务中可能包含一个或多个 SQL 语句,这些语句要么都执行,要么都不执行,作为一个关系型数据库,MySQL 支持事务。

单元中的每条 SQL 语句都相互依赖,形成一个整体

  • 如果某条 SQL 语句执行失败或者出现错误,那么整个单元就会回滚,撤回到事务最初的状态

  • 如果单元中所有的 SQL 语句都执行成功,则事务就顺利执行

事务的四大特征:ACID

  • 原子性 (atomicity)
  • 一致性 (consistency)
  • 隔离性 (isolaction)
  • 持久性 (durability)

事务的几种状态:

  • 活动的(active):事务对应的数据库操作正在执行中
  • 部分提交的(partially committed):事务的最后一个操作执行完,但是内存还没刷新至磁盘
  • 失败的(failed):当事务处于活动状态或部分提交状态时,如果数据库遇到了错误或刷脏失败,或者用户主动停止当前的事务
  • 中止的(aborted):失败状态的事务回滚完成后的状态
  • 提交的(committed):当处于部分提交状态的事务刷脏成功,就处于提交状态

事务管理

基本操作

事务管理的三个步骤

  1. 开启事务:记录回滚点,并通知服务器,将要执行一组操作,要么同时成功、要么同时失败

  2. 执行 SQL 语句:执行具体的一条或多条 SQL 语句

  3. 结束事务(提交|回滚)

    • 提交:没出现问题,数据进行更新
    • 回滚:出现问题,数据恢复到开启事务时的状态

事务操作:

  • 显式开启事务

    1
    2
    START TRANSACTION [READ ONLY|READ WRITE|WITH CONSISTENT SNAPSHOT]; #可以跟一个或多个状态,最后的是一致性读
    BEGIN [WORK];

    说明:不填状态默认是读写事务

  • 回滚事务,用来手动中止事务

    1
    ROLLBACK;
  • 提交事务,显示执行是手动提交,MySQL 默认为自动提交

    1
    COMMIT;
  • 保存点:在事务的执行过程中设置的还原点,调用 ROLLBACK 时可以指定回滚到哪个点

    1
    2
    3
    SAVEPOINT point_name;						#设置保存点
    RELEASE point_name #删除保存点
    ROLLBACK [WORK] TO [SAVEPOINT] point_name #回滚至某个保存点,不填默认回滚到事务执行之前的状态
  • 操作演示

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    -- 开启事务
    START TRANSACTION;

    -- 张三给李四转账500元
    -- 1.张三账户-500
    UPDATE account SET money=money-500 WHERE NAME='张三';
    -- 2.李四账户+500
    UPDATE account SET money=money+500 WHERE NAME='李四';

    -- 回滚事务(出现问题)
    ROLLBACK;

    -- 提交事务(没出现问题)
    COMMIT;

提交方式

提交方式的相关语法:

  • 查看事务提交方式

    1
    2
    SELECT @@AUTOCOMMIT;  		-- 会话,1 代表自动提交    0 代表手动提交
    SELECT @@GLOBAL.AUTOCOMMIT; -- 系统
  • 修改事务提交方式

    1
    2
    SET @@AUTOCOMMIT=数字;	-- 系统
    SET AUTOCOMMIT=数字; -- 会话
  • 系统变量的操作

    1
    2
    SET [GLOBAL|SESSION] 变量名 = 值;					-- 默认是会话
    SET @@[(GLOBAL|SESSION).]变量名 = 值; -- 默认是系统
    1
    SHOW [GLOBAL|SESSION] VARIABLES [LIKE '变量%'];	  -- 默认查看会话内系统变量值

工作原理:

  • 自动提交:如果没有 START TRANSACTION 显式地开始一个事务,那么每条 SQL 语句都会被当做一个事务执行提交操作;显式开启事务后,会在本次事务结束(提交或回滚)前暂时关闭自动提交
  • 手动提交:不需要显式的开启事务,所有的 SQL 语句都在一个事务中,直到执行了提交或回滚,然后进入下一个事务
  • 隐式提交:存在一些特殊的命令,在事务中执行了这些命令会马上强制执行 COMMIT 提交事务
    • DDL 语句 (CREATE/DROP/ALTER)、LOCK TABLES 语句、LOAD DATA 导入数据语句、主从复制语句等
    • 当一个事务还没提交或回滚,显式的开启一个事务会隐式的提交上一个事务

事务 ID

事务在执行过程中对某个表执行了增删改操作或者创建表,就会为当前事务分配一个独一无二的事务 ID(对临时表并不会分配 ID),如果当前事务没有被分配 ID,默认是 0

说明:只读事务不能对普通的表进行增删改操作,但是可以对临时表增删改,读写事务可以对数据表执行增删改查操作

事务 ID 本质上就是一个数字,服务器在内存中维护一个全局变量:

  • 每当需要为某个事务分配 ID,就会把全局变量的值赋值给事务 ID,然后变量自增 1
  • 每当变量值为 256 的倍数时,就将该变量的值刷新到系统表空间的 Max Trx ID 属性中,该属性占 8 字节
  • 系统再次启动后,会读取表空间的 Max Trx ID 属性到内存,加上 256 后赋值给全局变量,因为关机时的事务 ID 可能并不是 256 的倍数,会比 Max Trx ID 大,所以需要加上 256 保持事务 ID 是一个递增的数字

聚簇索引的行记录除了完整的数据,还会自动添加 trx_id、roll_pointer 隐藏列,如果表中没有主键并且没有非空唯一索引,也会添加一个 row_id 的隐藏列作为聚簇索引


隔离级别

四种级别

事务的隔离级别:多个客户端操作时,各个客户端的事务之间应该是隔离的,不同的事务之间不该互相影响,而如果多个事务操作同一批数据时,则需要设置不同的隔离级别,否则就会产生问题。

隔离级别分类:

隔离级别 名称 会引发的问题 数据库默认隔离级别
Read Uncommitted 读未提交 脏读、不可重复读、幻读
Read Committed 读已提交 不可重复读、幻读 Oracle / SQL Server
Repeatable Read 可重复读 幻读 MySQL
Serializable 可串行化

一般来说,隔离级别越低,系统开销越低,可支持的并发越高,但隔离性也越差

  • 脏写 (Dirty Write):当两个或多个事务选择同一行,最初的事务修改的值被后面事务修改的值覆盖,所有的隔离级别都可以避免脏写(又叫丢失更新),因为有行锁

  • 脏读 (Dirty Reads):在一个事务处理过程中读取了另一个未提交的事务中修改过的数据

  • 不可重复读 (Non-Repeatable Reads):在一个事务处理过程中读取了另一个事务中修改并已提交的数据

    可重复读的意思是不管读几次,结果都一样,可以重复的读,可以理解为快照读,要读的数据集不会发生变化

  • 幻读 (Phantom Reads):在事务中按某个条件先后两次查询数据库,后一次查询查到了前一次查询没有查到的行,数据条目发生了变化。比如查询某数据不存在,准备插入此记录,但执行插入时发现此记录已存在,无法插入

隔离级别操作语法:

  • 查询数据库隔离级别

    1
    2
    SELECT @@TX_ISOLATION;			-- 会话
    SELECT @@GLOBAL.TX_ISOLATION; -- 系统
  • 修改数据库隔离级别

    1
    SET GLOBAL TRANSACTION ISOLATION LEVEL 级别字符串;

加锁分析

InnoDB 存储引擎支持事务,所以加锁分析是基于该存储引擎

  • Read Uncommitted 级别,任何操作都不会加锁

  • Read Committed 级别,增删改操作会加写锁(行锁),读操作不加锁

    在 Server 层过滤条件时发现不满足的记录会调用 unlock_row 方法释放该记录的行锁,保证最后只有满足条件的记录加锁,但是扫表过程中每条记录的加锁操作不能省略。所以对数据量很大的表做批量修改时,如果无法使用相应的索引(全表扫描),在 Server 过滤数据时就会特别慢,出现虽然没有修改某些行的数据,但是还是被锁住了的现象(锁表),这种情况同样适用于 RR

  • Repeatable Read 级别,增删改操作会加写锁,读操作不加锁。因为读写锁不兼容,加了读锁后其他事务就无法修改数据,影响了并发性能,为了保证隔离性和并发性,MySQL 通过 MVCC 解决了读写冲突。RR 级别下的锁有很多种,锁机制章节详解

  • Serializable 级别,读加共享锁,写加排他锁,读写互斥,使用的悲观锁的理论,实现简单,数据更加安全,但是并发能力非常差

    • 串行化:让所有事务按顺序单独执行,写操作会加写锁,读操作会加读锁
    • 可串行化:让所有操作相同数据的事务顺序执行,通过加锁实现

参考文章:https://tech.meituan.com/2014/08/20/innodb-lock.html


原子特性

实现方式

原子性是指事务是一个不可分割的工作单位,事务的操作如果成功就必须要完全应用到数据库,失败则不能对数据库有任何影响。比如事务中一个 SQL 语句执行失败,则已执行的语句也必须回滚,数据库退回到事务前的状态

InnoDB 存储引擎提供了两种事务日志:redo log(重做日志)和 undo log(回滚日志)

  • redo log 用于保证事务持久性
  • undo log 用于保证事务原子性和隔离性

undo log 属于逻辑日志,根据每行操作进行记录,记录了 SQL 执行相关的信息,用来回滚行记录到某个版本

当事务对数据库进行修改时,InnoDB 会先记录对应的 undo log,如果事务执行失败或调用了 rollback 导致事务回滚,InnoDB 会根据 undo log 的内容做与之前相反的操作

  • 对于每个 insert,回滚时会执行 delete

  • 对于每个 delete,回滚时会执行 insert

  • 对于每个 update,回滚时会执行一个相反的 update,把数据修改回去

参考文章:https://www.cnblogs.com/kismetv/p/10331633.html


DML 解析

INSERT

乐观插入:当前数据页的剩余空间充足,直接将数据进行插入

悲观插入:当前数据页的剩余空间不足,需要进行页分裂,申请一个新的页面来插入数据,会造成更多的 redo log,undo log 影响不大

当向某个表插入一条记录,实际上需要向聚簇索引和所有二级索引都插入一条记录,但是 undo log 只针对聚簇索引记录,在回滚时会根据聚簇索引去所有的二级索引进行回滚操作

roll_pointer 是一个指针,指向记录对应的 undo log 日志,一条记录就是一个数据行,行格式中的 roll_pointer 就指向 undo log


DELETE

插入到页面中的记录会根据 next_record 属性组成一个单向链表,这个链表称为正常链表,被删除的记录也会通过 next_record 组成一个垃圾链表,该链表中所占用的存储空间可以被重新利用,并不会直接清除数据

在页面 Page Header 中,PAGE_FREE 属性指向垃圾链表的头节点,删除的工作过程:

  • 将要删除的记录的 delete_flag 位置为 1,其他不做修改,这个过程叫 delete mark

  • 在事务提交前,delete_flag = 1 的记录一直都会处于中间状态

  • 事务提交后,有专门的线程将 delete_flag = 1 的记录从正常链表移除并加入垃圾链表,这个过程叫 purge

    purge 线程在执行删除操作时会创建一个 ReadView,根据事务的可见性移除数据(隔离特性部分详解)

当有新插入的记录时,首先判断 PAGE_FREE 指向的头节点是否足够容纳新纪录:

  • 如果可以容纳新纪录,就会直接重用已删除的记录的存储空间,然后让 PAGE_FREE 指向垃圾链表的下一个节点
  • 如果不能容纳新纪录,就直接向页面申请新的空间存储,并不会遍历垃圾链表

重用已删除的记录空间,可能会造成空间碎片,当数据页容纳不了一条记录时,会判断将碎片空间加起来是否可以容纳,判断为真就会重新组织页内的记录:

  • 开辟一个临时页面,将页内记录一次插入到临时页面,此时临时页面时没有碎片的
  • 把临时页面的内容复制到本页,这样就解放出了内存碎片,但是会耗费很大的性能资源

UPDATE

执行 UPDATE 语句,对于更新主键和不更新主键有两种不同的处理方式

不更新主键的情况:

  • 就地更新(in-place update),如果更新后的列和更新前的列占用的存储空间一样大,就可以直接在原记录上修改

  • 先删除旧纪录,再插入新纪录,这里的删除不是 delete mark,而是直接将记录加入垃圾链表,并且修改页面的相应的控制信息,执行删除的线程不是 purge,是执行更新的用户线程,插入新记录时可能造成页空间不足,从而导致页分裂

更新主键的情况:

  • 将旧纪录进行 delete mark,在更新语句提交后由 purge 线程移入垃圾链表
  • 根据更新的各列的值创建一条新纪录,插入到聚簇索引中

在对一条记录修改前会将记录的隐藏列 trx_id 和 roll_pointer 的旧值记录到 undo log 对应的属性中,这样当前记录的 roll_pointer 指向当前 undo log 记录,当前 undo log 记录的 roll_pointer 指向旧的 undo log 记录,形成一个版本链

UPDATE、DELETE 操作产生的 undo 日志会用于其他事务的 MVCC 操作,所以不能立即删除,INSERT 可以删除的原因是 MVCC 是对现有数据的快照


回滚日志

undo log 是采用段的方式来记录,Rollback Segement 称为回滚段,本质上就是一个类型是 Rollback Segement Header 的页面

每个回滚段中有 1024 个 undo slot,每个 slot 存放 undo 链表页面的头节点页号,每个链表对应一个叫 undo log segment 的段

  • 在以前老版本,只支持 1 个 Rollback Segement,只能记录 1024 个 undo log segment
  • MySQL5.5 开始支持 128 个 Rollback Segement,支持 128*1024 个 undo 操作

工作流程:

  • 事务执行前需要到系统表空间第 5 号页面中分配一个回滚段(页),获取一个 Rollback Segement Header 页面的地址

  • 回滚段页面有 1024 个 undo slot,首先去回滚段的两个 cached 链表获取缓存的 slot,缓存中没有就在回滚段页面中找一个可用的 undo slot 分配给当前事务

  • 如果是缓存中获取的 slot,则该 slot 对应的 undo log segment 已经分配了,需要重新分配,然后从 undo log segment 中申请一个页面作为日志链表的头节点,并填入对应的 slot 中

  • 每个事务 undo 日志在记录的时候占用两个 undo 页面的组成链表,分别为 insert undo 链表和 update undo 链表,链表的头节点页面为 first undo page 会包含一些管理信息,其他页面为 normal undo page

    说明:事务执行过程的临时表也需要两个 undo 链表,不和普通表共用,这些链表并不是事务开始就分配,而是按需分配


隔离特性

实现方式

隔离性是指,事务内部的操作与其他事务是隔离的,多个并发事务之间要相互隔离,不能互相干扰

  • 严格的隔离性,对应了事务隔离级别中的 serializable,实际应用中对性能考虑很少使用可串行化

  • 与原子性、持久性侧重于研究事务本身不同,隔离性研究的是不同事务之间的相互影响

隔离性让并发情形下的事务之间互不干扰:

  • 一个事务的写操作对另一个事务的写操作(写写):锁机制保证隔离性
  • 一个事务的写操作对另一个事务的读操作(读写):MVCC 保证隔离性

锁机制:事务在修改数据之前,需要先获得相应的锁,获得锁之后,事务便可以修改数据;该事务操作期间,这部分数据是锁定的,其他事务如果需要修改数据,需要等待当前事务提交或回滚后释放锁(详解见锁机制)


并发控制

MVCC 全称 Multi-Version Concurrency Control,即多版本并发控制,用来解决读写冲突的无锁并发控制,可以在发生读写请求冲突时不用加锁解决,这个读是指的快照读(也叫一致性读或一致性无锁读),而不是当前读:

  • 快照读:实现基于 MVCC,因为是多版本并发,所以快照读读到的数据不一定是当前最新的数据,有可能是历史版本的数据
  • 当前读:又叫加锁读,读取数据库记录是当前最新的版本(产生幻读、不可重复读),可以对读取的数据进行加锁,防止其他事务修改数据,是悲观锁的一种操作,读写操作加共享锁或者排他锁和串行化事务的隔离级别都是当前读

数据库并发场景:

  • 读-读:不存在任何问题,也不需要并发控制

  • 读-写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读

  • 写-写:有线程安全问题,可能会存在脏写(丢失更新)问题

MVCC 的优点:

  • 在并发读写数据库时,做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了并发读写的性能
  • 可以解决脏读,不可重复读等事务隔离问题(加锁也能解决),但不能解决更新丢失问题(写锁会解决)

提高读写和写写的并发性能:

  • MVCC + 悲观锁:MVCC 解决读写冲突,悲观锁解决写写冲突
  • MVCC + 乐观锁:MVCC 解决读写冲突,乐观锁解决写写冲突

参考文章:https://www.jianshu.com/p/8845ddca3b23


实现原理

隐藏字段

实现原理主要是隐藏字段,undo日志,Read View 来实现的

InnoDB 存储引擎,数据库中的聚簇索引每行数据,除了自定义的字段,还有数据库隐式定义的字段:

  • DB_TRX_ID:最近修改事务 ID,记录创建该数据或最后一次修改该数据的事务 ID
  • DB_ROLL_PTR:回滚指针,指向记录对应的 undo log 日志,undo log 中又指向上一个旧版本的 undo log
  • DB_ROW_ID:隐含的自增 ID(隐藏主键),如果数据表没有主键,InnoDB 会自动以 DB_ROW_ID 作为聚簇索引


版本链

undo log 是逻辑日志,记录的是每个事务对数据执行的操作,而不是记录的全部数据,要根据 undo log 逆推出以往事务的数据

undo log 的作用:

  • 保证事务进行 rollback 时的原子性和一致性,当事务进行回滚的时候可以用 undo log 的数据进行恢复
  • 用于 MVCC 快照读,通过读取 undo log 的历史版本数据可以实现不同事务版本号都拥有自己独立的快照数据版本

undo log 主要分为两种:

  • insert undo log:事务在 insert 新记录时产生的 undo log,只在事务回滚时需要,并且在事务提交后可以被立即丢弃

  • update undo log:事务在进行 update 或 delete 时产生的 undo log,在事务回滚时需要,在快照读时也需要。不能随意删除,只有在当前读或事务回滚不涉及该日志时,对应的日志才会被 purge 线程统一清除

每次对数据库记录进行改动,都会产生的新版本的 undo log,随着更新次数的增多,所有的版本都会被 roll_pointer 属性连接成一个链表,把这个链表称之为版本链,版本链的头节点就是当前的最新的 undo log,链尾就是最早的旧 undo log

说明:因为 DELETE 删除记录,都是移动到垃圾链表中,不是真正的删除,所以才可以通过版本链访问原始数据

注意:undo 是逻辑日志,这里只是直观的展示出来

工作流程:

  • 有个事务插入 persion 表一条新记录,name 为 Jerry,age 为 24
  • 事务 1 修改该行数据时,数据库会先对该行加排他锁,然后先记录 undo log,然后修改该行 name 为 Tom,并且修改隐藏字段的事务 ID 为当前事务 1 的 ID(默认为 1 之后递增),回滚指针指向拷贝到 undo log 的副本记录,事务提交后,释放锁
  • 以此类推

读视图

Read View 是事务进行读数据操作时产生的读视图,该事务执行快照读的那一刻会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的 ID,用来做可见性判断,根据视图判断当前事务能够看到哪个版本的数据

注意:这里的快照并不是把所有的数据拷贝一份副本,而是由 undo log 记录的逻辑日志,根据库中的数据进行计算出历史数据

工作流程:将版本链的头节点的事务 ID(最新数据事务 ID,大概率不是当前线程)DB_TRX_ID 取出来,与系统当前活跃事务的 ID 对比进行可见性分析,不可见就通过 DB_ROLL_PTR 回滚指针去取出 undo log 中的下一个 DB_TRX_ID 比较,直到找到最近的满足可见性的 DB_TRX_ID,该事务 ID 所在的旧记录就是当前事务能看见的最新的记录

Read View 几个属性:

  • m_ids:生成 Read View 时当前系统中活跃的事务 id 列表(未提交的事务集合,当前事务也在其中)
  • min_trx_id:生成 Read View 时当前系统中活跃的最小的事务 id,也就是 m_ids 中的最小值(已提交的事务集合)
  • max_trx_id:生成 Read View 时当前系统应该分配给下一个事务的 id 值,m_ids 中的最大值加 1(未开始事务)
  • creator_trx_id:生成该 Read View 的事务的事务 id,就是判断该 id 的事务能读到什么数据

creator 创建一个 Read View,进行可见性算法分析:(解决了读未提交)

  • db_trx_id == creator_trx_id:表示这个数据就是当前事务自己生成的,自己生成的数据自己肯定能看见,所以此数据对 creator 是可见的

  • db_trx_id < min_trx_id:该版本对应的事务 ID 小于 Read view 中的最小活跃事务 ID,则这个事务在当前事务之前就已经被提交了,对 creator 可见(因为比已提交的最大事务 ID 小的并不一定已经提交,所以应该判断是否在活跃事务列表)

  • db_trx_id >= max_trx_id:该版本对应的事务 ID 大于 Read view 中当前系统的最大事务 ID,则说明该数据是在当前 Read view 创建之后才产生的,对 creator 不可见

  • min_trx_id<= db_trx_id < max_trx_id:判断 db_trx_id 是否在活跃事务列表 m_ids 中

    • 在列表中,说明该版本对应的事务正在运行,数据不能显示(不能读到未提交的数据
    • 不在列表中,说明该版本对应的事务已经被提交,数据可以显示(可以读到已经提交的数据

工作流程

表 user 数据

1
2
id		name		age
1 张三 18

Transaction 20:

1
2
3
START TRANSACTION;	-- 开启事务
UPDATE user SET name = '李四' WHERE id = 1;
UPDATE user SET name = '王五' WHERE id = 1;

Transaction 60:

1
2
START TRANSACTION;	-- 开启事务
-- 操作表的其他数据

ID 为 0 的事务创建 Read View:

  • m_ids:20、60
  • min_trx_id:20
  • max_trx_id:61
  • creator_trx_id:0

只有红框部分才复合条件,所以只有张三对应的版本的数据可以被看到

参考视频:https://www.bilibili.com/video/BV1t5411u7Fg


二级索引

只有在聚簇索引中才有 trx_id 和 roll_pointer 的隐藏列,对于二级索引判断可见性的方式:

  • 二级索引页面的 Page Header 中有一个 PAGE_MAX_TRX_ID 属性,代表修改当前页面的最大的事务 ID,SELECT 语句访问某个二级索引时会判断 ReadView 的 min_trx_id 是否大于该属性,大于说明该页面的所有属性对 ReadView 可见
  • 如果属性判断不可见,就需要利用二级索引获取主键值进行回表操作,得到聚簇索引后按照聚簇索引的可见性判断的方法操作

RC RR

Read View 用于支持 RC(Read Committed,读已提交)和 RR(Repeatable Read,可重复读)隔离级别的实现,所以 SELECT 在 RC 和 RR 隔离级别使用 MVCC 读取记录

RR、RC 生成时机:

  • RC 隔离级别下,每次读取数据前都会生成最新的 Read View(当前读)
  • RR 隔离级别下,在第一次数据读取时才会创建 Read View(快照读)

RC、RR 级别下的 InnoDB 快照读区别

  • RC 级别下,事务中每次快照读都会新生成一个 Read View,这就是在 RC 级别下的事务中可以看到别的事务提交的更新的原因

  • RR 级别下,某个事务的对某条记录的第一次快照读会创建一个 Read View, 将当前系统活跃的其他事务记录起来,此后在调用快照读的时候,使用的是同一个 Read View,所以一个事务的查询结果每次都是相同的

    RR 级别下,通过 START TRANSACTION WITH CONSISTENT SNAPSHOT 开启事务,会在执行该语句后立刻生成一个 Read View,不是在执行第一条 SELECT 语句时生成(所以说 START TRANSACTION 并不是事务的起点,执行第一条语句才算起点)

解决幻读问题:

  • 快照读:通过 MVCC 来进行控制的,在可重复读隔离级别下,普通查询是快照读,是不会看到别的事务插入的数据的,但是并不能完全避免幻读

    场景:RR 级别,T1 事务开启,创建 Read View,此时 T2 去 INSERT 新的一行然后提交,然后 T1 去 UPDATE 该行会发现更新成功,并且把这条新记录的 trx_id 变为当前的事务 id,所以对当前事务就是可见的。因为 Read View 并不能阻止事务去更新数据,更新数据都是先读后写并且是当前读,读取到的是最新版本的数据

  • 当前读:通过 next-key 锁(行锁 + 间隙锁)来解决问题


持久特性

实现方式

持久性是指一个事务一旦被提交了,那么对数据库中数据的改变就是永久性的,接下来的其他操作或故障不应该对其有任何影响。

Buffer Pool 的使用提高了读写数据的效率,但是如果 MySQL 宕机,此时 Buffer Pool 中修改的数据还没有刷新到磁盘,就会导致数据的丢失,事务的持久性无法保证,所以引入了 redo log 日志:

  • redo log 记录数据页的物理修改,而不是某一行或某几行的修改,用来恢复提交后的数据页,只能恢复到最后一次提交的位置
  • redo log 采用的是 WAL(Write-ahead logging,预写式日志),所有修改要先写入日志,再更新到磁盘,保证了数据不会因 MySQL 宕机而丢失,从而满足了持久性要求
  • 简单的 redo log 是纯粹的物理日志,复杂的 redo log 会存在物理日志和逻辑日志

工作过程:MySQL 发生了宕机,InnoDB 会判断一个数据页在崩溃恢复时丢失了更新,就会将它读到内存,然后根据 redo log 内容更新内存,更新完成后,内存页变成脏页,然后进行刷脏

缓冲池的刷脏策略

  • redo log 文件是固定大小的,如果写满了就要擦除以前的记录,在擦除之前需要把对应的更新持久化到磁盘中
  • Buffer Pool 内存不足,需要淘汰部分数据页(LRU 链表尾部),如果淘汰的是脏页,就要先将脏页写到磁盘(要避免大事务)
  • 系统空闲时,后台线程会自动进行刷脏(Flush 链表部分已经详解)
  • MySQL 正常关闭时,会把内存的脏页都刷新到磁盘上

重做日志

日志缓冲

服务器启动时会向操作系统申请一片连续内存空间作为 redo log buffer(重做日志缓冲区),可以通过 innodb_log_buffer_size 系统变量指定 redo log buffer 的大小,默认是 16MB

log buffer 被划分为若干 redo log block(块,类似数据页的概念),每个默认大小 512 字节,每个 block 由 12 字节的 log block head、496 字节的 log block body、4 字节的 log block trailer 组成

  • 当数据修改时,先修改 Change Buffer 中的数据,然后在 redo log buffer 记录这次操作,写入 log buffer 的过程是顺序写入的(先写入前面的 block,写满后继续写下一个)
  • log buffer 中有一个指针 buf_free,来标识该位置之前都是填满的 block,该位置之后都是空闲区域(碰撞指针

MySQL 规定对底层页面的一次原子访问称为一个 Mini-Transaction(MTR),比如在 B+ 树上插入一条数据就算一个 MTR

  • 一个事务包含若干个 MTR,一个 MTR 对应一组若干条 redo log,一组 redo log 是不可分割的,在进行数据恢复时也把一组 redo log 当作一个不可分割的整体处理

  • 所以不是每生成一条 redo 日志就将其插入到 log buffer 中,而是一个 MTR 结束后将一组 redo 日志写入 log buffer

InnoDB 的 redo log 是固定大小的,redo 日志在磁盘中以文件组的形式存储,同一组中的每个文件大小一样格式一样,

  • innodb_log_group_home_dir 代表磁盘存储 redo log 的文件目录,默认是当前数据目录
  • innodb_log_file_size 代表文件大小,默认 48M,innodb_log_files_in_group 代表文件个数,默认 2 最大 100,所以日志的文件大小为 innodb_log_file_size * innodb_log_files_in_group

redo 日志文件也是由若干个 512 字节的 block 组成,日志文件的前 2048 个字节(前 4 个 block)用来存储一些管理信息,以后的用来存储 log buffer 中的 block 镜像

注意:block 并不代表一组 redo log,一组日志可能占用不到一个 block 或者几个 block,依赖于 MTR 的大小


日志刷盘

redo log 需要在事务提交时将日志写入磁盘,但是比将内存中的 Buffer Pool 修改的数据写入磁盘的速度快,原因:

  • 刷脏是随机 IO,因为每次修改的数据位置随机;redo log 和 binlog 都是顺序写,磁盘的顺序 IO 比随机 IO 速度要快
  • 刷脏是以数据页(Page)为单位的,一个页上的一个小修改都要整页写入;redo log 中只包含真正需要写入的部分,减少无效 IO
  • 组提交机制,可以大幅度降低磁盘的 IO 消耗

InnoDB 引擎会在适当的时候,把内存中 redo log buffer 持久化(fsync)到磁盘,具体的刷盘策略

  • 在事务提交时需要进行刷盘,通过修改参数 innodb_flush_log_at_trx_commit 设置:
    • 0:表示当提交事务时,并不将缓冲区的 redo 日志写入磁盘,而是等待后台线程每秒刷新一次
    • 1:在事务提交时将缓冲区的 redo 日志同步写入到磁盘,保证一定会写入成功(默认值)
    • 2:在事务提交时将缓冲区的 redo 日志异步写入到磁盘,不能保证提交时肯定会写入,只是有这个动作。日志已经在操作系统的缓存,如果操作系统没有宕机而 MySQL 宕机,也是可以恢复数据的
  • 写入 redo log buffer 的日志超过了总容量的一半,就会将日志刷入到磁盘文件,这会影响执行效率,所以开发中应避免大事务
  • 服务器关闭时
  • checkpoint 时(下小节详解)
  • 并行的事务提交(组提交)时,会将将其他事务的 redo log 持久化到磁盘。假设事务 A 已经写入 redo log buffer 中,这时另外一个线程的事务 B 提交,如果 innodb_flush_log_at_trx_commit 设置的是 1,那么事务 B 要把 redo log buffer 里的日志全部持久化到磁盘,因为多个事务共用一个 redo log buffer,所以一次 fsync 可以刷盘多个事务的 redo log,提升了并发量

服务器启动后 redo 磁盘空间不变,所以 redo 磁盘中的日志文件是被循环使用的,采用循环写数据的方式,写完尾部重新写头部,所以要确保头部 log 对应的修改已经持久化到磁盘


日志序号

lsn (log sequence number) 代表已经写入的 redo 日志量、flushed_to_disk_lsn 指刷新到磁盘中的 redo 日志量,两者都是全局变量,如果两者的值相同,说明 log buffer 中所有的 redo 日志都已经持久化到磁盘

工作过程:写入 log buffer 数据时,buf_free 会进行偏移,偏移量就会加到 lsn 上

MTR 的执行过程中修改过的页对应的控制块会加到 Buffer Pool 的 flush 链表中,链表中脏页是按照第一次修改的时间进行排序的(头插),控制块中有两个指针用来记录脏页被修改的时间:

  • oldest_modification:第一次修改 Buffer Pool 中某个缓冲页时,将修改该页的 MTR 开始时对应的 lsn 值写入这个属性
  • newest_modification:每次修改页面,都将 MTR 结束时全局的 lsn 值写入这个属性,所以该值是该页面最后一次修改后的 lsn 值

全局变量 checkpoint_lsn 表示当前系统可以被覆盖的 redo 日志总量,当 redo 日志对应的脏页已经被刷新到磁盘后,该文件空间就可以被覆盖重用,此时执行一次 checkpoint 来更新 checkpoint_lsn 的值存入管理信息(刷脏和执行一次 checkpoint 并不是同一个线程),该值的增量就代表磁盘文件中当前位置向后可以被覆盖的文件的量,所以该值是一直增大的

checkpoint:从 flush 链表尾部中找出还未刷脏的页面,该页面是当前系统中最早被修改的脏页,该页面之前产生的脏页都已经刷脏,然后将该页 oldest_modification 值赋值给 checkpoint_lsn,因为 lsn 小于该值时产生的 redo 日志都可以被覆盖了

但是在系统忙碌时,后台线程的刷脏操作不能将脏页快速刷出,导致系统无法及时执行 checkpoint ,这时需要用户线程从 flush 链表中把最早修改的脏页刷新到磁盘中,然后执行 checkpoint

1
write pos ------- checkpoint_lsn // 两值之间的部分表示可以写入的日志量,当 pos 追赶上 lsn 时必须执行 checkpoint

使用命令可以查看当前 InnoDB 存储引擎各种 lsn 的值:

1
SHOW ENGINE INNODB STATUS\G

崩溃恢复

恢复的起点:在从 redo 日志文件组的管理信息中获取最近发生 checkpoint 的信息,从 checkpoint_lsn 对应的日志文件开始恢复

恢复的终点:扫描日志文件的 block,block 的头部记录着当前 block 使用了多少字节,填满的 block 总是 512 字节, 如果某个 block 不是 512 字节,说明该 block 就是需要恢复的最后一个 block

恢复的过程:按照 redo log 依次执行恢复数据,优化方式

  • 使用哈希表:根据 redo log 的 space id 和 page number 属性计算出哈希值,将对同一页面的修改放入同一个槽里,可以一次性完成对某页的恢复,避免了随机 IO
  • 跳过已经刷新到磁盘中的页面:数据页的 File Header 中的 FILE_PAGE_LSN 属性(类似 newest_modification)表示最近一次修改页面时的 lsn 值,数据页被刷新到磁盘中,那么该页 lsn 属性肯定大于 checkpoint_lsn

参考书籍:https://book.douban.com/subject/35231266/


工作流程

日志对比

MySQL 中还存在 binlog(二进制日志)也可以记录写操作并用于数据的恢复,保证数据不丢失,二者的区别是:

  • 作用不同:redo log 是用于 crash recovery (故障恢复),保证 MySQL 宕机也不会影响持久性;binlog 是用于 point-in-time recovery 的,保证服务器可以基于时间点恢复数据,此外 binlog 还用于主从复制
  • 层次不同:redo log 是 InnoDB 存储引擎实现的,而 binlog 是MySQL的 Server 层实现的,同时支持 InnoDB 和其他存储引擎
  • 内容不同:redo log 是物理日志,内容基于磁盘的 Page;binlog 的内容是二进制的,根据 binlog_format 参数的不同,可能基于SQL 语句、基于数据本身或者二者的混合(日志部分详解)
  • 写入时机不同:binlog 在事务提交时一次写入;redo log 的写入时机相对多元

binlog 为什么不支持崩溃恢复?

  • binlog 记录的是语句,并不记录数据页级的数据(哪个页改了哪些地方),所以没有能力恢复数据页
  • binlog 是追加写,保存全量的日志,没有标志确定从哪个点开始的数据是已经刷盘了,而 redo log 只要在 checkpoint_lsn 后面的就是没有刷盘的

更新记录

更新一条记录的过程:写之前一定先读

  • 在 B+ 树中定位到该记录,如果该记录所在的页面不在 Buffer Pool 里,先将其加载进内存

  • 首先更新该记录对应的聚簇索引,更新聚簇索引记录时:

    • 更新记录前向 undo 页面写 undo 日志,由于这是更改页面,所以需要记录一下相应的 redo 日志

      注意:修改 undo页面也是在修改页面,事务凡是修改页面就需要先记录相应的 redo 日志

    • 然后先记录对应的的 redo 日志(等待 MTR 提交后写入 redo log buffer),最后进行真正的更新记录

  • 更新其他的二级索引记录,不会再记录 undo log,只记录 redo log 到 buffer 中

  • 在一条更新语句执行完成后(也就是将所有待更新记录都更新完了),就会开始记录该语句对应的 binlog 日志,此时记录的 binlog 并没有刷新到硬盘上,还在内存中,在事务提交时才会统一将该事务运行过程中的所有 binlog 日志刷新到硬盘

假设表中有字段 id 和 a,存在一条 id = 1, a = 2 的记录,此时执行更新语句:

1
update table set a=2 where id=1;

InnoDB 会真正的去执行把值修改成 (1,2) 这个操作,先加行锁,在去更新,并不会提前判断相同就不修改了

参考文章:https://mp.weixin.qq.com/s/wcJ2KisSaMnfP4nH5NYaQA


两段提交

当客户端执行 COMMIT 语句或者在自动提交的情况下,MySQL 内部开启一个 XA 事务,分两阶段来完成 XA 事务的提交:

1
update T set c=c+1 where ID=2;

流程说明:执行引擎将这行新数据读入到内存中(Buffer Pool)后,先将此次更新操作记录到 redo log buffer 里,然后更新记录。最后将 redo log 刷盘后事务处于 prepare 状态,执行器会生成这个操作的 binlog,并把 binlog 写入磁盘,完成提交

两阶段:

  • Prepare 阶段:存储引擎将该事务的 redo 日志刷盘,并且将本事务的状态设置为 PREPARE,代表执行完成随时可以提交事务
  • Commit 阶段:先将事务执行过程中产生的 binlog 刷新到硬盘,再执行存储引擎的提交工作,引擎把 redo log 改成提交状态

redo log 和 binlog 都可以用于表示事务的提交状态,而两阶段提交就是让这两个状态保持逻辑上的一致,也有利于主从复制,更好的保持主从数据的一致性


数据恢复

系统崩溃前没有提交的事务的 redo log 可能已经刷盘(定时线程或者 checkpoint),怎么处理崩溃恢复?

工作流程:获取 undo 链表首节点页面的 undo segement header 中的 TRX_UNDO_STATE 属性,表示当前链表的事务属性,事务状态是活跃(未提交)的就全部回滚,如果是 PREPARE 状态,就需要根据 binlog 的状态进行判断:

  • 如果在时刻 A 发生了崩溃(crash),由于此时 binlog 还没完成,所以需要进行回滚
  • 如果在时刻 B 发生了崩溃,redo log 和 binlog 有一个共同的数据字段叫 XID,崩溃恢复的时候,会按顺序扫描 redo log:
    • 如果 redo log 里面的事务是完整的,也就是已经有了 commit 标识,说明 binlog 也已经记录完整,直接从 redo log 恢复数据
    • 如果 redo log 里面的事务只有 prepare,就根据 XID 去 binlog 中判断对应的事务是否存在并完整,如果完整可以恢复数据

判断一个事务的 binlog 是否完整的方法:

  • statement 格式的 binlog,最后会有 COMMIT
  • row 格式的 binlog,最后会有一个 XID event
  • MySQL 5.6.2 版本以后,引入了 binlog-checksum 参数用来验证 binlog 内容的正确性(可能日志中间出错)

参考文章:https://time.geekbang.org/column/article/73161


刷脏优化

系统在进行刷脏时会占用一部分系统资源,会影响系统的性能,产生系统抖动

  • 一个查询要淘汰的脏页个数太多,会导致查询的响应时间明显变长
  • 日志写满,更新全部堵住,写性能跌为 0,这种情况对敏感业务来说,是不能接受的

InnoDB 刷脏页的控制策略:

  • innodb_io_capacity 参数代表磁盘的读写能力,建议设置成磁盘的 IOPS(每秒的 IO 次数)
  • 刷脏速度参考两个因素:脏页比例和 redo log 写盘速度
    • 参数 innodb_max_dirty_pages_pct 是脏页比例上限,默认值是 75%,InnoDB 会根据当前的脏页比例,算出一个范围在 0 到 100 之间的数字
    • InnoDB 每次写入的日志都有一个序号,当前写入的序号跟 checkpoint 对应的序号之间的差值,InnoDB 根据差值算出一个范围在 0 到 100 之间的数字
    • 两者较大的值记为 R,执行引擎按照 innodb_io_capacity 定义的能力乘以 R% 来控制刷脏页的速度
  • innodb_flush_neighbors 参数置为 1 代表控制刷脏时检查相邻的数据页,如果也是脏页就一起刷脏,并检查邻居的邻居,这个行为会一直蔓延直到不是脏页,在 MySQL 8.0 中该值的默认值是 0,不建议开启此功能

一致特性

一致性是指事务执行前后,数据库的完整性约束没有被破坏,事务执行的前后都是合法的数据状态。

数据库的完整性约束包括但不限于:实体完整性(如行的主键存在且唯一)、列完整性(如字段的类型、大小、长度要符合要求)、外键约束、用户自定义完整性(如转账前后,两个账户余额的和应该不变)

实现一致性的措施:

  • 保证原子性、持久性和隔离性,如果这些特性无法保证,事务的一致性也无法保证
  • 数据库本身提供保障,例如不允许向整形列插入字符串值、字符串长度不能超过列的限制等
  • 应用层面进行保障,例如如果转账操作只扣除转账者的余额,而没有增加接收者的余额,无论数据库实现的多么完美,也无法保证状态的一致

锁机制

基本介绍

锁机制:数据库为了保证数据的一致性,在共享的资源被并发访问时变得安全有序所设计的一种规则

利用 MVCC 性质进行读取的操作叫一致性读,读取数据前加锁的操作叫锁定读

锁的分类:

  • 按操作分类:
    • 共享锁:也叫读锁。对同一份数据,多个事务读操作可以同时加锁而不互相影响 ,但不能修改数据
    • 排他锁:也叫写锁。当前的操作没有完成前,会阻断其他操作的读取和写入
  • 按粒度分类:
    • 表级锁:会锁定整个表,开销小,加锁快;不会出现死锁;锁定力度大,发生锁冲突概率高,并发度最低,偏向 MyISAM
    • 行级锁:会锁定当前操作行,开销大,加锁慢;会出现死锁;锁定力度小,发生锁冲突概率低,并发度高,偏向 InnoDB
    • 页级锁:锁的力度、发生冲突的概率和加锁开销介于表锁和行锁之间,会出现死锁,并发性能一般
  • 按使用方式分类:
    • 悲观锁:每次查询数据时都认为别人会修改,很悲观,所以查询时加锁
    • 乐观锁:每次查询数据时都认为别人不会修改,很乐观,但是更新时会判断一下在此期间别人有没有去更新这个数据
  • 不同存储引擎支持的锁

    存储引擎 表级锁 行级锁 页级锁
    MyISAM 支持 不支持 不支持
    InnoDB 支持 支持 不支持
    MEMORY 支持 不支持 不支持
    BDB 支持 不支持 支持

从锁的角度来说:表级锁更适合于以查询为主,只有少量按索引条件更新数据的应用,如 Web 应用;而行级锁则更适合于有大量按索引条件并发更新少量不同数据,同时又有并查询的应用,如一些在线事务处理系统


内存结构

对一条记录加锁的本质就是在内存中创建一个锁结构与之关联,结构包括

  • 事务信息:锁对应的事务信息,一个锁属于一个事务
  • 索引信息:对于行级锁,需要记录加锁的记录属于哪个索引
  • 表锁和行锁信息:表锁记录着锁定的表,行锁记录了 Space ID 所在表空间、Page Number 所在的页号、n_bits 使用了多少比特
  • type_mode:一个 32 比特的数,被分成 lock_mode、lock_type、rec_lock_type 三个部分
    • lock_mode:锁模式,记录是共享锁、排他锁、意向锁之类
    • lock_type:代表表级锁还是行级锁
    • rec_lock_type:代表行锁的具体类型和 is_waiting 属性,is_waiting = true 时表示当前事务尚未获取到锁,处于等待状态。事务获取锁后的锁结构是 is_waiting 为 false,释放锁时会检查是否与当前记录关联的锁结构,如果有就唤醒对应事务的线程

一个事务可能操作多条记录,为了节省内存,满足下面条件的锁使用同一个锁结构:

  • 在同一个事务中的加锁操作
  • 被加锁的记录在同一个页面中
  • 加锁的类型是一样的
  • 加锁的状态是一样的

Server

MySQL 里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL)

MDL 叫元数据锁,主要用来保护 MySQL 内部对象的元数据,保证数据读写的正确性,当对一个表做增删改查的时候,加 MDL 读锁;当要对表做结构变更操作 DDL 的时候,加 MDL 写锁,两种锁不相互兼容,所以可以保证 DDL、DML、DQL 操作的安全

说明:DDL 操作执行前会隐式提交当前会话的事务,因为 DDL 一般会在若干个特殊事务中完成,开启特殊事务前需要提交到其他事务

MDL 锁的特性:

  • MDL 锁不需要显式使用,在访问一个表的时候会被自动加上,在事务开始时申请,整个事务提交后释放(执行完单条语句不释放)

  • MDL 锁是在 Server 中实现,不是 InnoDB 存储引擎层能直接实现的锁

  • MDL 锁还能实现其他粒度级别的锁,比如全局锁、库级别的锁、表空间级别的锁

FLUSH TABLES WITH READ LOCK 简称(FTWRL),全局读锁,让整个库处于只读状态,DDL DML 都被阻塞,工作流程:

  1. 上全局读锁(lock_global_read_lock)
  2. 清理表缓存(close_cached_tables)
  3. 上全局 COMMIT 锁(make_global_read_lock_block_commit)

该命令主要用于备份工具做一致性备份,由于 FTWRL 需要持有两把全局的 MDL 锁,并且还要关闭所有表对象,因此杀伤性很大


MyISAM

表级锁

MyISAM 存储引擎只支持表锁,这也是 MySQL 开始几个版本中唯一支持的锁类型

MyISAM 引擎在执行查询语句之前,会自动给涉及到的所有表加读锁,在执行增删改之前,会自动给涉及的表加写锁,这个过程并不需要用户干预,所以用户一般不需要直接用 LOCK TABLE 命令给 MyISAM 表显式加锁

  • 加锁命令:(对 InnoDB 存储引擎也适用)

    读锁:所有连接只能读取数据,不能修改

    写锁:其他连接不能查询和修改数据

    1
    2
    3
    4
    5
    -- 读锁
    LOCK TABLE table_name READ;

    -- 写锁
    LOCK TABLE table_name WRITE;
  • 解锁命令:

    1
    2
    -- 将当前会话所有的表进行解锁
    UNLOCK TABLES;

锁的兼容性:

  • 对 MyISAM 表的读操作,不会阻塞其他用户对同一表的读请求,但会阻塞对同一表的写请求
  • 对 MyISAM 表的写操作,则会阻塞其他用户对同一表的读和写操作

![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-MyISAM 锁的兼容性.png)

锁调度:MyISAM 的读写锁调度是写优先,因为写锁后其他线程不能做任何操作,大量的更新会使查询很难得到锁,从而造成永远阻塞,所以 MyISAM 不适合做写为主的表的存储引擎


锁操作

读锁

两个客户端操作 Client 1和 Client 2,简化为 C1、C2

  • 数据准备:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    CREATE TABLE `tb_book` (
    `id` INT(11) AUTO_INCREMENT,
    `name` VARCHAR(50) DEFAULT NULL,
    `publish_time` DATE DEFAULT NULL,
    `status` CHAR(1) DEFAULT NULL,
    PRIMARY KEY (`id`)
    ) ENGINE=MYISAM DEFAULT CHARSET=utf8 ;

    INSERT INTO tb_book (id, NAME, publish_time, STATUS) VALUES(NULL,'java编程思想','2088-08-01','1');
    INSERT INTO tb_book (id, NAME, publish_time, STATUS) VALUES(NULL,'mysql编程思想','2088-08-08','0');
  • C1、C2 加读锁,同时查询可以正常查询出数据

    1
    2
    LOCK TABLE tb_book READ;	-- C1、C2
    SELECT * FROM tb_book; -- C1、C2

    ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-MyISAM 读锁1.png)

  • C1 加读锁,C1、C2 查询未锁定的表,C1 报错,C2 正常查询

    1
    2
    LOCK TABLE tb_book READ;	-- C1
    SELECT * FROM tb_user; -- C1、C2

    ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-MyISAM 读锁2.png)

    C1、C2 执行插入操作,C1 报错,C2 等待获取

    1
    INSERT INTO tb_book VALUES(NULL,'Spring高级','2088-01-01','1');	-- C1、C2

    ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-MyISAM 读锁3.png)

    当在 C1 中释放锁指令 UNLOCK TABLES,C2 中的 INSERT 语句立即执行


写锁

两个客户端操作 Client 1和 Client 2,简化为 C1、C2


锁状态

  • 查看锁竞争:

    1
    SHOW OPEN TABLES;

    In_user:表当前被查询使用的次数,如果该数为零,则表是打开的,但是当前没有被使用

    Name_locked:表名称是否被锁定,名称锁定用于取消表或对表进行重命名等操作

    1
    LOCK TABLE tb_book READ;	-- 执行命令

  • 查看锁状态:

    1
    SHOW STATUS LIKE 'Table_locks%';

    ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-MyISAM 锁状态.png)

    Table_locks_immediate:指的是能立即获得表级锁的次数,每立即获取锁,值加 1

    Table_locks_waited:指的是不能立即获取表级锁而需要等待的次数,每等待一次,该值加 1,此值高说明存在着较为严重的表级锁争用情况


InnoDB

行级锁

记录锁

InnoDB 与 MyISAM 的最大不同有两点:一是支持事务;二是采用了行级锁,InnoDB 同时支持表锁和行锁

行级锁,也称为记录锁(Record Lock),InnoDB 实现了以下两种类型的行锁:

  • 共享锁 (S):又称为读锁,简称 S 锁,多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改
  • 排他锁 (X):又称为写锁,简称 X 锁,不能与其他锁并存,获取排他锁的事务是可以对数据读取和修改

RR 隔离界别下,对于 UPDATE、DELETE 和 INSERT 语句,InnoDB 会自动给涉及数据集加排他锁(行锁),在 commit 时自动释放;对于普通 SELECT 语句,不会加任何锁(只是针对 InnoDB 层来说的,因为在 Server 层会加 MDL 读锁),通过 MVCC 防止并发冲突

在事务中加的锁,并不是不需要了就释放,而是在事务中止或提交时自动释放,这个就是两阶段锁协议。所以一般将更新共享资源(并发高)的 SQL 放到事务的最后执行,可以让其他线程尽量的减少等待时间

锁的兼容性:

  • 共享锁和共享锁 兼容
  • 共享锁和排他锁 冲突
  • 排他锁和排他锁 冲突
  • 排他锁和共享锁 冲突

显式给数据集加共享锁或排他锁:加锁读就是当前读,读取的是最新数据

1
2
SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE	-- 共享锁
SELECT * FROM table_name WHERE ... FOR UPDATE -- 排他锁

注意:锁默认会锁聚簇索引(锁就是加在索引上),但是当使用覆盖索引时,加共享锁只锁二级索引,不锁聚簇索引


锁操作

两个客户端操作 Client 1和 Client 2,简化为 C1、C2

  • 环境准备

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    CREATE TABLE test_innodb_lock(
    id INT(11),
    name VARCHAR(16),
    sex VARCHAR(1)
    )ENGINE = INNODB DEFAULT CHARSET=utf8;

    INSERT INTO test_innodb_lock VALUES(1,'100','1');
    -- ..........

    CREATE INDEX idx_test_innodb_lock_id ON test_innodb_lock(id);
    CREATE INDEX idx_test_innodb_lock_name ON test_innodb_lock(name);
  • 关闭自动提交功能:

    1
    SET AUTOCOMMIT=0;	-- C1、C2

    正常查询数据:

    1
    SELECT * FROM test_innodb_lock;	-- C1、C2
  • 查询 id 为 3 的数据,正常查询:

    1
    SELECT * FROM test_innodb_lock WHERE id=3;	-- C1、C2

    ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-InnoDB 锁操作1.png)

  • C1 更新 id 为 3 的数据,但不提交:

    1
    UPDATE test_innodb_lock SET name='300' WHERE id=3;	-- C1

    ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-InnoDB 锁操作2.png)

    C2 查询不到 C1 修改的数据,因为隔离界别为 REPEATABLE READ,C1 提交事务,C2 查询:

    1
    COMMIT;	-- C1

    ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-InnoDB 锁操作3.png)

    提交后仍然查询不到 C1 修改的数据,因为隔离级别可以防止脏读、不可重复读,所以 C2 需要提交才可以查询到其他事务对数据的修改:

    1
    2
    COMMIT;	-- C2
    SELECT * FROM test_innodb_lock WHERE id=3; -- C2

    ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-InnoDB 锁操作4.png)

  • C1 更新 id 为 3 的数据,但不提交,C2 也更新 id 为 3 的数据:

    1
    2
    UPDATE test_innodb_lock SET name='3' WHERE id=3;	-- C1
    UPDATE test_innodb_lock SET name='30' WHERE id=3; -- C2

    ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-InnoDB 锁操作5.png)

    当 C1 提交,C2 直接解除阻塞,直接更新

  • 操作不同行的数据:

    1
    2
    UPDATE test_innodb_lock SET name='10' WHERE id=1;	-- C1
    UPDATE test_innodb_lock SET name='30' WHERE id=3; -- C2

    ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-InnoDB 锁操作6.png)

    由于 C1、C2 操作的不同行,获取不同的行锁,所以都可以正常获取行锁


锁分类

间隙锁

InnoDB 会对间隙(GAP)进行加锁,就是间隙锁 (RR 隔离级别下才有该锁)。间隙锁之间不存在冲突关系,多个事务可以同时对一个间隙加锁,但是间隙锁会阻止往这个间隙中插入一个记录的操作

InnoDB 加锁的基本单位是 next-key lock,该锁是行锁和 gap lock 的组合(X or S 锁),但是加锁过程是分为间隙锁和行锁两段执行

  • 可以保护当前记录和前面的间隙,遵循左开右闭原则,单纯的是间隙锁左开右开
  • 假设有 10、11、13,那么可能的间隙锁包括:(负无穷,10]、(10,11]、(11,13]、(13,正无穷)

几种索引的加锁情况:

  • 唯一索引加锁在值存在时是行锁,next-key lock 会退化为行锁,值不存在会变成间隙锁
  • 普通索引加锁会继续向右遍历到不满足条件的值为止,next-key lock 退化为间隙锁
  • 范围查询无论是否是唯一索引,都需要访问到不满足条件的第一个值为止
  • 对于联合索引且是唯一索引,如果 where 条件只包括联合索引的一部分,那么会加间隙锁

间隙锁优点:RR 级别下间隙锁可以解决事务的一部分的幻读问题,通过对间隙加锁,可以防止读取过程中数据条目发生变化。一部分的意思是不会对全部间隙加锁,只能加锁一部分的间隙

间隙锁危害:

  • 当锁定一个范围的键值后,即使某些不存在的键值也会被无辜的锁定,造成在锁定的时候无法插入锁定键值范围内的任何数据,在某些场景下这可能会对性能造成很大的危害,影响并发度
  • 事务 A B 同时锁住一个间隙后,A 往当前间隙插入数据时会被 B 的间隙锁阻塞,B 也执行插入间隙数据的操作时就会产生死锁

现场演示:


意向锁

InnoDB 为了支持多粒度的加锁,允许行锁和表锁同时存在,支持在不同粒度上的加锁操作,InnoDB 增加了意向锁(Intention Lock)

意向锁是将锁定的对象分为多个层次,意向锁意味着事务希望在更细粒度上进行加锁,意向锁分为两种:

  • 意向共享锁(IS):事务有意向对表加共享锁
  • 意向排他锁(IX):事务有意向对表加排他锁

IX,IS 是表级锁,不会和行级的 X,S 锁发生冲突,意向锁是在加表级锁之前添加,为了在加表级锁时可以快速判断表中是否有记录被上锁,比如向一个表添加表级 X 锁的时:

  • 没有意向锁,则需要遍历整个表判断是否有锁定的记录
  • 有了意向锁,首先判断是否存在意向锁,然后判断该意向锁与即将添加的表级锁是否兼容即可,因为意向锁的存在代表有表级锁的存在或者即将有表级锁的存在

兼容性如下所示:

插入意向锁 Insert Intention Lock 是在插入一行记录操作之前设置的一种间隙锁,是行级锁

插入意向锁释放了一种插入信号,即多个事务在相同的索引间隙插入时如果不是插入相同的间隙位置就不需要互相等待。假设某列有索引,只要两个事务插入位置不同,如事务 A 插入 3,事务 B 插入 4,那么就可以同时插入


自增锁

系统会自动给 AUTO_INCREMENT 修饰的列进行递增赋值,实现方式:

  • AUTO_INC 锁:表级锁,执行插入语句时会自动添加,在该语句执行完成后释放,并不是事务结束
  • 轻量级锁:为插入语句生成 AUTO_INCREMENT 修饰的列时获取该锁,生成以后释放掉,不需要等到插入语句执行完后释放

系统变量 innodb_autoinc_lock_mode 控制采取哪种方式:

  • 0:全部采用 AUTO_INC 锁
  • 1:全部采用轻量级锁
  • 2:混合使用,在插入记录的数量确定时采用轻量级锁,不确定时采用 AUTO_INC 锁

隐式锁

一般情况下 INSERT 语句是不需要在内存中生成锁结构的,会进行隐式的加锁,保护的是插入后的安全

注意:如果插入的间隙被其他事务加了间隙锁,此次插入会被阻塞,并在该间隙插入一个插入意向锁

  • 聚簇索引:索引记录有 trx_id 隐藏列,表示最后改动该记录的事务 id,插入数据后事务 id 就是当前事务。其他事务想获取该记录的锁时会判断当前记录的事务 id 是否是活跃的,如果不是就可以正常加锁;如果是就创建一个 X 的锁结构,该锁的 is_waiting 是 false,为自己的事务创建一个锁结构,is_waiting 是 true(类似 Java 中的锁升级)
  • 二级索引:获取数据页 Page Header 中的 PAGE_MAX_TRX_ID 属性,代表修改当前页面的最大的事务 ID,如果小于当前活跃的最小事务 id,就证明插入该数据的事务已经提交,否则就需要获取到主键值进行回表操作

隐式锁起到了延迟生成锁的效果,如果其他事务与隐式锁没有冲突,就可以避免锁结构的生成,节省了内存资源

INSERT 在两种情况下会生成锁结构:

  • 重复键:在插入主键或唯一二级索引时遇到重复的键值会报错,在报错前需要对对应的聚簇索引进行加锁

    • 隔离级别 <= Read Uncommitted,加 S 型 Record Lock
    • 隔离级别 >= Repeatable Read,加 S 型 next_key 锁
  • 外键检查:如果待插入的记录在父表中可以找到,会对父表的记录加 S 型 Record Lock。如果待插入的记录在父表中找不到

    • 隔离级别 <= Read Committed,不加锁
    • 隔离级别 >= Repeatable Read,加间隙锁

锁优化

优化锁

InnoDB 存储引擎实现了行级锁定,虽然在锁定机制的实现方面带来了性能损耗可能比表锁会更高,但是在整体并发处理能力方面要远远优于 MyISAM 的表锁,当系统并发量较高的时候,InnoDB 的整体性能远远好于 MyISAM

但是使用不当可能会让 InnoDB 的整体性能表现不仅不能比 MyISAM 高,甚至可能会更差

优化建议:

  • 尽可能让所有数据检索都能通过索引来完成,避免无索引行锁升级为表锁
  • 合理设计索引,尽量缩小锁的范围
  • 尽可能减少索引条件及索引范围,避免间隙锁
  • 尽量控制事务大小,减少锁定资源量和时间长度
  • 尽可使用低级别事务隔离(需要业务层面满足需求)

锁升级

索引失效造成行锁升级为表锁,不通过索引检索数据,全局扫描的过程中 InnoDB 会将对表中的所有记录加锁,实际效果和表锁一样,实际开发过程应避免出现索引失效的状况

  • 查看当前表的索引:

    1
    SHOW INDEX FROM test_innodb_lock;
  • 关闭自动提交功能:

    1
    SET AUTOCOMMIT=0;	-- C1、C2
  • 执行更新语句:

    1
    2
    UPDATE test_innodb_lock SET sex='2' WHERE name=10;	-- C1
    UPDATE test_innodb_lock SET sex='2' WHERE id=3; -- C2

    ![](https://seazean.oss-cn-beijing.aliyuncs.com/img/DB/MySQL-InnoDB 锁升级.png)

    索引失效:执行更新时 name 字段为 varchar 类型,造成索引失效,最终行锁变为表锁


死锁

不同事务由于互相持有对方需要的锁而导致事务都无法继续执行的情况称为死锁

死锁情况:线程 A 修改了 id = 1 的数据,请求修改 id = 2 的数据,线程 B 修改了 id = 2 的数据,请求修改 id = 1 的数据,产生死锁

解决策略:

  • 直接进入等待直到超时,超时时间可以通过参数 innodb_lock_wait_timeout 来设置,默认 50 秒,但是时间的设置不好控制,超时可能不是因为死锁,而是因为事务处理比较慢,所以一般不采取该方式

  • 主动死锁检测,发现死锁后主动回滚死锁链条中较小的一个事务,让其他事务得以继续执行,将参数 innodb_deadlock_detect 设置为 on,表示开启该功能(事务较小的意思就是事务执行过程中插入、删除、更新的记录条数)

    死锁检测并不是每个语句都要检测,只有在加锁访问的行上已经有锁时,当前事务被阻塞了才会检测,也是从当前事务开始进行检测

通过执行 SHOW ENGINE INNODB STATUS 可以查看最近发生的一次死循环,全局系统变量 innodb_print_all_deadlocks 设置为 on,就可以将每个死锁信息都记录在 MySQL 错误日志中

死锁一般是行级锁,当表锁发生死锁时,会在事务中访问其他表时直接报错,破坏了持有并等待的死锁条件


锁状态

查看锁信息

1
SHOW STATUS LIKE 'innodb_row_lock%';

参数说明:

  • Innodb_row_lock_current_waits:当前正在等待锁定的数量

  • Innodb_row_lock_time:从系统启动到现在锁定总时间长度

  • Innodb_row_lock_time_avg:每次等待所花平均时长

  • Innodb_row_lock_time_max:从系统启动到现在等待最长的一次所花的时间

  • Innodb_row_lock_waits:系统启动后到现在总共等待的次数

当等待的次数很高,而且每次等待的时长也不短的时候,就需要分析系统中为什么会有如此多的等待,然后根据分析结果制定优化计划

查看锁状态:

1
2
SELECT * FROM information_schema.innodb_locks;	#锁的概况
SHOW ENGINE INNODB STATUS\G; #InnoDB整体状态,其中包括锁的情况

lock_id 是锁 id;lock_trx_id 为事务 id;lock_mode 为 X 代表排它锁(写锁);lock_type 为 RECORD 代表锁为行锁(记录锁)


乐观锁

悲观锁:在整个数据处理过程中,将数据处于锁定状态,为了保证事务的隔离性,就需要一致性锁定读。读取数据时给加锁,其它事务无法修改这些数据,修改删除数据时也加锁,其它事务同样无法读取这些数据

悲观锁和乐观锁使用前提:

  • 对于读的操作远多于写的操作的时候,一个更新操作加锁会阻塞所有的读取操作,降低了吞吐量,最后需要释放锁,锁是需要一些开销的,这时候可以选择乐观锁
  • 如果是读写比例差距不是非常大或者系统没有响应不及时,吞吐量瓶颈的问题,那就不要去使用乐观锁,它增加了复杂度,也带来了业务额外的风险,这时候可以选择悲观锁

乐观锁的实现方式:就是 CAS,比较并交换

  • 版本号

    1. 给数据表中添加一个 version 列,每次更新后都将这个列的值加 1

    2. 读取数据时,将版本号读取出来,在执行更新的时候,比较版本号

    3. 如果相同则执行更新,如果不相同,说明此条数据已经发生了变化

    4. 用户自行根据这个通知来决定怎么处理,比如重新开始一遍,或者放弃本次更新

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      -- 创建city表
      CREATE TABLE city(
      id INT PRIMARY KEY AUTO_INCREMENT, -- 城市id
      NAME VARCHAR(20), -- 城市名称
      VERSION INT -- 版本号
      );

      -- 添加数据
      INSERT INTO city VALUES (NULL,'北京',1),(NULL,'上海',1),(NULL,'广州',1),(NULL,'深圳',1);

      -- 修改北京为北京市
      -- 1.查询北京的version
      SELECT VERSION FROM city WHERE NAME='北京';
      -- 2.修改北京为北京市,版本号+1。并对比版本号
      UPDATE city SET NAME='北京市',VERSION=VERSION+1 WHERE NAME='北京' AND VERSION=1;
  • 时间戳

    • 和版本号方式基本一样,给数据表中添加一个列,名称无所谓,数据类型需要是 timestamp
    • 每次更新后都将最新时间插入到此列
    • 读取数据时,将时间读取出来,在执行更新的时候,比较时间
    • 如果相同则执行更新,如果不相同,说明此条数据已经发生了变化

乐观锁的异常情况:如果 version 被其他事务抢先更新,则在当前事务中更新失败,trx_id 没有变成当前事务的 ID,当前事务再次查询还是旧值,就会出现值没变但是更新不了的现象(anomaly)

解决方案:每次 CAS 更新不管成功失败,就结束当前事务;如果失败则重新起一个事务进行查询更新


主从

基本介绍

主从复制是指将主数据库的 DDL 和 DML 操作通过二进制日志传到从库服务器中,然后在从库上对这些日志重新执行(也叫重做),从而使得从库和主库的数据保持同步

MySQL 支持一台主库同时向多台从库进行复制,从库同时也可以作为其他从服务器的主库,实现链状复制

MySQL 复制的优点主要包含以下三个方面:

  • 主库出现问题,可以快速切换到从库提供服务

  • 可以在从库上执行查询操作,从主库中更新,实现读写分离

  • 可以在从库中执行备份,以避免备份期间影响主库的服务(备份时会加全局读锁)


主从复制

主从结构

MySQL 的主从之间维持了一个长连接。主库内部有一个线程,专门用于服务从库的长连接,连接过程:

  • 从库执行 change master 命令,设置主库的 IP、端口、用户名、密码以及要从哪个位置开始请求 binlog,这个位置包含文件名和日志偏移量
  • 从库执行 start slave 命令,这时从库会启动两个线程,就是图中的 io_thread 和 sql_thread,其中 io_thread 负责与主库建立连接
  • 主库校验完用户名、密码后,开始按照从传过来的位置,从本地读取 binlog 发给从库,开始主从复制

主从复制原理图:

主从复制主要依赖的是 binlog,MySQL 默认是异步复制,需要三个线程:

  • binlog thread:在主库事务提交时,负责把数据变更记录在二进制日志文件 binlog 中,并通知 slave 有数据更新
  • I/O thread:负责从主服务器上拉取二进制日志,并将 binlog 日志内容依次写到 relay log 中转日志的最末端,并将新的 binlog 文件名和 offset 记录到 master-info 文件中,以便下一次读取日志时从指定 binlog 日志文件及位置开始读取新的 binlog 日志内容
  • SQL thread:监测本地 relay log 中新增了日志内容,读取中继日志并重做其中的 SQL 语句,从库在 relay-log.info 中记录当前应用中继日志的文件名和位点以便下一次执行

同步与异步:

  • 异步复制有数据丢失风险,例如数据还未同步到从库,主库就给客户端响应,然后主库挂了,此时从库晋升为主库的话数据是缺失的
  • 同步复制,主库需要将 binlog 复制到所有从库,等所有从库响应了之后主库才进行其他逻辑,这样的话性能很差,一般不会选择
  • MySQL 5.7 之后出现了半同步复制,有参数可以选择成功同步几个从库就返回响应

主主结构

主主结构就是两个数据库之间总是互为主从关系,这样在切换的时候就不用再修改主从关系

循环复制:在库 A 上更新了一条语句,然后把生成的 binlog 发给库 B,库 B 执行完这条更新语句后也会生成 binlog,会再发给 A

解决方法:

  • 两个库的 server id 必须不同,如果相同则它们之间不能设定为主主关系
  • 一个库接到 binlog 并在重放的过程中,生成与原 binlog 的 server id 相同的新的 binlog
  • 每个库在收到从主库发过来的日志后,先判断 server id,如果跟自己的相同,表示这个日志是自己生成的,就直接丢弃这个日志

主从延迟

延迟原因

正常情况主库执行更新生成的所有 binlog,都可以传到从库并被正确地执行,从库就能达到跟主库一致的状态,这就是最终一致性

主从延迟是主从之间是存在一定时间的数据不一致,就是同一个事务在从库执行完成的时间和主库执行完成的时间的差值,即 T2-T1

  • 主库 A 执行完成一个事务,写入 binlog,该时刻记为 T1
  • 日志传给从库 B,从库 B 执行完这个事务,该时刻记为 T2

通过在从库执行 show slave status 命令,返回结果会显示 seconds_behind_master 表示当前从库延迟了多少秒

  • 每一个事务的 binlog 都有一个时间字段,用于记录主库上写入的时间
  • 从库取出当前正在执行的事务的时间字段,跟系统的时间进行相减,得到的就是 seconds_behind_master

主从延迟的原因:

  • 从库的机器性能比主库的差,导致从库的复制能力弱
  • 从库的查询压力大,建立一主多从的结构
  • 大事务的执行,主库必须要等到事务完成之后才会写入 binlog,导致从节点出现应用 binlog 延迟
  • 主库的 DDL,从库与主库的 DDL 同步是串行进行,DDL 在主库执行时间很长,那么从库也会消耗同样的时间
  • 锁冲突问题也可能导致从节点的 SQL 线程执行慢

主从同步问题永远都是一致性和性能的权衡,需要根据实际的应用场景,可以采取下面的办法:

  • 优化 SQL,避免慢 SQL,减少批量操作

  • 降低多线程大事务并发的概率,优化业务逻辑

  • 业务中大多数情况查询操作要比更新操作更多,搭建一主多从结构,让这些从库来分担读的压力

  • 尽量采用短的链路,主库和从库服务器的距离尽量要短,提升端口带宽,减少 binlog 传输的网络延时

  • 实时性要求高的业务读强制走主库,从库只做备份


并行复制

MySQL5.6

高并发情况下,主库的会产生大量的 binlog,在从库中有两个线程 IO Thread 和 SQL Thread 单线程执行,会导致主库延迟变大。为了改善复制延迟问题,MySQL 5.6 版本增加了并行复制功能,以采用多线程机制来促进执行

coordinator 就是原来的 SQL Thread,并行复制中它不再直接更新数据,只负责读取中转日志和分发事务

  • 线程分配完成并不是立即执行,为了防止造成更新覆盖,更新同一 DB 的两个事务必须被分发到同一个工作线程
  • 同一个事务不能被拆开,必须放到同一个工作线程

MySQL 5.6 版本的策略:每个线程对应一个 hash 表,用于保存当前这个线程的执行队列里的事务所涉及的表,hash 表的 key 是数据库 名,value 是一个数字,表示队列中有多少个事务修改这个库,适用于主库上有多个 DB 的情况

每个事务在分发的时候,跟线程的冲突(事务操作的是同一个库)关系包括以下三种情况:

  • 如果跟所有线程都不冲突,coordinator 线程就会把这个事务分配给最空闲的线程
  • 如果只跟一个线程冲突,coordinator 线程就会把这个事务分配给这个存在冲突关系的线程
  • 如果跟多于一个线程冲突,coordinator 线程就进入等待状态,直到和这个事务存在冲突关系的线程只剩下 1 个

优缺点:

  • 构造 hash 值的时候很快,只需要库名,而且一个实例上 DB 数也不会很多,不会出现需要构造很多个项的情况
  • 不要求 binlog 的格式,statement 格式的 binlog 也可以很容易拿到库名(日志章节详解了 binlog)
  • 主库上的表都放在同一个 DB 里面,这个策略就没有效果了;或者不同 DB 的热点不同,比如一个是业务逻辑库,一个是系统配置库,那也起不到并行的效果,需要把相同热度的表均匀分到这些不同的 DB 中,才可以使用这个策略

MySQL5.7

MySQL 5.7 由参数 slave-parallel-type 来控制并行复制策略:

  • 配置为 DATABASE,表示使用 MySQL 5.6 版本的按库(DB)并行策略
  • 配置为 LOGICAL_CLOCK,表示的按提交状态并行执行

按提交状态并行复制策略的思想是:

  • 所有处于 commit 状态的事务可以并行执行;同时处于 prepare 状态的事务,在从库执行时是可以并行的
  • 处于 prepare 状态的事务,与处于 commit 状态的事务之间,在从库执行时也是可以并行的

MySQL 5.7.22 版本里,MySQL 增加了一个新的并行复制策略,基于 WRITESET 的并行复制,新增了一个参数 binlog-transaction-dependency-tracking,用来控制是否启用这个新策略:

  • COMMIT_ORDER:表示根据同时进入 prepare 和 commit 来判断是否可以并行的策略

  • WRITESET:表示的是对于每个事务涉及更新的每一行,计算出这一行的 hash 值,组成该事务的 writeset 集合,如果两个事务没有操作相同的行,也就是说它们的 writeset 没有交集,就可以并行(按行并行

    为了唯一标识,这个 hash 表的值是通过 库名 + 表名 + 索引名 + 值(表示的是某一行)计算出来的

  • WRITESET_SESSION:是在 WRITESET 的基础上多了一个约束,即在主库上同一个线程先后执行的两个事务,在备库执行的时候,要保证相同的先后顺序

MySQL 5.7.22 按行并发的优势:

  • writeset 是在主库生成后直接写入到 binlog 里面的,这样在备库执行的时候,不需要解析 binlog 内容,节省了计算量
  • 不需要把整个事务的 binlog 都扫一遍才能决定分发到哪个线程,更省内存
  • 从库的分发策略不依赖于 binlog 内容,所以 binlog 是 statement 格式也可以,更节约内存(因为 row 才记录更改的行)

MySQL 5.7.22 的并行复制策略在通用性上是有保证的,但是对于表上没主键、唯一和外键约束的场景,WRITESET 策略也是没法并行的,也会暂时退化为单线程模型

参考文章:https://time.geekbang.org/column/article/77083


读写分离

读写延迟

读写分离:可以降低主库的访问压力,提高系统的并发能力

  • 主库不建查询的索引,从库建查询的索引。因为索引需要维护的,比如插入一条数据,不仅要在聚簇索引上面插入,对应的二级索引也得插入
  • 将读操作分到从库了之后,可以在主库把查询要用的索引删了,减少写操作对主库的影响

读写分离产生了读写延迟,造成数据的不一致性。假如客户端执行完一个更新事务后马上发起查询,如果查询选择的是从库的话,可能读到的还是以前的数据,叫过期读

解决方案:

  • 强制将写之后立刻读的操作转移到主库,比如刚注册的用户,直接登录从库查询可能查询不到,先走主库登录
  • 二次查询,如果从库查不到数据,则再去主库查一遍,由 API 封装,比较简单,但导致主库压力大
  • 更新主库后,读从库之前先 sleep 一下,类似于执行一条 select sleep(1) 命令,大多数情况下主备延迟在 1 秒之内

确保机制

无延迟

确保主备无延迟的方法:

  • 每次从库执行查询请求前,先判断 seconds_behind_master 是否已经等于 0,如果不等于那就等到参数变为 0 执行查询请求
  • 对比位点,Master_Log_File 和 Read_Master_Log_Pos 表示的是读到的主库的最新位点,Relay_Master_Log_File 和 Exec_Master_Log_Pos 表示的是备库执行的最新位点,这两组值完全相同就说明接收到的日志已经同步完成
  • 对比 GTID 集合,Retrieved_Gtid_Set 是备库收到的所有日志的 GTID 集合,Executed_Gtid_Set 是备库所有已经执行完成的 GTID 集合,如果这两个集合相同也表示备库接收到的日志都已经同步完成

半同步

半同步复制就是 semi-sync replication,适用于一主一备的场景,工作流程:

  • 事务提交的时候,主库把 binlog 发给从库
  • 从库收到 binlog 以后,发回给主库一个 ack,表示收到了
  • 主库收到这个 ack 以后,才能给客户端返回事务完成的确认

在一主多从场景中,主库只要等到一个从库的 ack,就开始给客户端返回确认,这时在从库上执行查询请求,有两种情况:

  • 如果查询是落在这个响应了 ack 的从库上,是能够确保读到最新数据
  • 如果查询落到其他从库上,它们可能还没有收到最新的日志,就会产生过期读的问题

在业务更新的高峰期,主库的位点或者 GTID 集合更新很快,导致从库来不及处理,那么两个位点等值判断就会一直不成立,很可能出现从库上迟迟无法响应查询请求的情况


等位点

从库执行判断位点的命令,参数 file 和 pos 指的是主库上的文件名和位置,timeout 可选,设置为正整数 N 表示最多等待 N 秒

1
SELECT master_pos_wait(file, pos[, timeout]);

命令正常返回的结果是一个正整数 M,表示从命令开始执行,到应用完 file 和 pos 表示的 binlog 位置,执行了多少事务

  • 如果执行期间,备库同步线程发生异常,则返回 NULL
  • 如果等待超过 N 秒,就返回 -1
  • 如果刚开始执行的时候,就发现已经执行过这个位置了,则返回 0

工作流程:先执行 trx1,再执行一个查询请求的逻辑,要保证能够查到正确的数据

  • trx1 事务更新完成后,马上执行 show master status 得到当前主库执行到的 File 和 Position
  • 选定一个从库执行判断位点语句,如果返回值是 >=0 的正整数,说明从库已经同步完事务,可以在这个从库执行查询语句
  • 如果出现其他情况,需要到主库执行查询语句

注意:如果所有的从库都延迟超过 timeout 秒,查询压力就都跑到主库上,所以需要进行权衡


等GTID

数据库开启了 GTID 模式,MySQL 提供了判断 GTID 的命令

1
SELECT wait_for_executed_gtid_set(gtid_set [, timeout])
  • 等待直到这个库执行的事务中包含传入的 gtid_set,返回 0
  • 超时返回 1

工作流程:先执行 trx1,再执行一个查询请求的逻辑,要保证能够查到正确的数据

  • trx1 事务更新完成后,从返回包直接获取这个事务的 GTID,记为 gtid
  • 选定一个从库执行查询语句,如果返回值是 0,则在这个从库执行查询语句,否则到主库执行查询语句

对比等待位点方法,减少了一次 show master status 的方法,将参数 session_track_gtids 设置为 OWN_GTID,然后通过 API 接口 mysql_session_track_get_first 从返回包解析出 GTID 的值即可

总结:所有的等待无延迟的方法,都需要根据具体的业务场景去判断实施

参考文章:https://time.geekbang.org/column/article/77636


负载均衡

负载均衡是应用中使用非常普遍的一种优化方法,机制就是利用某种均衡算法,将固定的负载量分布到不同的服务器上,以此来降低单台服务器的负载,达到优化的效果

  • 分流查询:通过 MySQL 的主从复制,实现读写分离,使增删改操作走主节点,查询操作走从节点,从而可以降低单台服务器的读写压力

  • 分布式数据库架构:适合大数据量、负载高的情况,具有良好的拓展性和高可用性。通过在多台服务器之间分布数据,可以实现在多台服务器之间的负载均衡,提高访问效率


主从搭建

master

  1. 在master 的配置文件(/etc/mysql/my.cnf)中,配置如下内容:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    #mysql 服务ID,保证整个集群环境中唯一
    server-id=1

    #mysql binlog 日志的存储路径和文件名
    log-bin=/var/lib/mysql/mysqlbin

    #错误日志,默认已经开启
    #log-err

    #mysql的安装目录
    #basedir

    #mysql的临时目录
    #tmpdir

    #mysql的数据存放目录
    #datadir

    #是否只读,1 代表只读, 0 代表读写
    read-only=0

    #忽略的数据, 指不需要同步的数据库
    binlog-ignore-db=mysql

    #指定同步的数据库
    #binlog-do-db=db01
  2. 执行完毕之后,需要重启 MySQL

  3. 创建同步数据的账户,并且进行授权操作:

    1
    2
    GRANT REPLICATION SLAVE ON *.* TO 'seazean'@'192.168.0.137' IDENTIFIED BY '123456';
    FLUSH PRIVILEGES;
  4. 查看 master 状态:

    1
    SHOW MASTER STATUS;

    • File:从哪个日志文件开始推送日志文件
    • Position:从哪个位置开始推送日志
    • Binlog_Ignore_DB:指定不需要同步的数据库

slave

  1. 在 slave 端配置文件中,配置如下内容:

    1
    2
    3
    4
    5
    #mysql服务端ID,唯一
    server-id=2

    #指定binlog日志
    log-bin=/var/lib/mysql/mysqlbin
  2. 执行完毕之后,需要重启 MySQL

  3. 指定当前从库对应的主库的IP地址、用户名、密码,从哪个日志文件开始的那个位置开始同步推送日志

    1
    CHANGE MASTER TO MASTER_HOST= '192.168.0.138', MASTER_USER='seazean', MASTER_PASSWORD='seazean', MASTER_LOG_FILE='mysqlbin.000001', MASTER_LOG_POS=413;
  4. 开启同步操作:

    1
    2
    START SLAVE;
    SHOW SLAVE STATUS;
  5. 停止同步操作:

    1
    STOP SLAVE;

验证

  1. 在主库中创建数据库,创建表并插入数据:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    CREATE DATABASE db01;
    USE db01;
    CREATE TABLE user(
    id INT(11) NOT NULL AUTO_INCREMENT,
    name VARCHAR(50) NOT NULL,
    sex VARCHAR(1),
    PRIMARY KEY (id)
    )ENGINE=INNODB DEFAULT CHARSET=utf8;

    INSERT INTO user(id,NAME,sex) VALUES(NULL,'Tom','1');
    INSERT INTO user(id,NAME,sex) VALUES(NULL,'Trigger','0');
    INSERT INTO user(id,NAME,sex) VALUES(NULL,'Dawn','1');
  2. 在从库中查询数据,进行验证:

    在从库中,可以查看到刚才创建的数据库:

    在该数据库中,查询表中的数据:


主从切换

正常切换

正常切换步骤:

  • 在开始切换之前先对主库进行锁表 flush tables with read lock,然后等待所有语句执行完成,切换完成后可以释放锁

  • 检查 slave 同步状态,在 slave 执行 show processlist

  • 停止 slave io 线程,执行命令 STOP SLAVE IO_THREAD

  • 提升 slave 为 master

    1
    2
    3
    4
    Stop slave;
    Reset master;
    Reset slave all;
    set global read_only=off; -- 设置为可更新状态
  • 将原来 master 变为 slave(参考搭建流程中的 slave 方法)

可靠性优先策略

  • 判断备库 B 现在的 seconds_behind_master,如果小于某个值(比如 5 秒)继续下一步,否则持续重试这一步
  • 把主库 A 改成只读状态,即把 readonly 设置为 true
  • 判断备库 B 的 seconds_behind_master 的值,直到这个值变成 0 为止(该步骤比较耗时,所以步骤 1 中要尽量等待该值变小)
  • 把备库 B 改成可读写状态,也就是把 readonly 设置为 false
  • 把业务请求切到备库 B

可用性优先策略:先做最后两步,会造成主备数据不一致的问题

参考文章:https://time.geekbang.org/column/article/76795


健康检测

主库发生故障后从库会上位,其他从库指向新的主库,所以需要一个健康检测的机制来判断主库是否宕机

  • select 1 判断,但是高并发下检测不出线程的锁等待的阻塞问题

  • 查表判断,在系统库(mysql 库)里创建一个表,比如命名为 health_check,里面只放一行数据,然后定期执行。但是当 binlog 所在磁盘的空间占用率达到 100%,所有的更新和事务提交语句都被阻塞,查询语句可以继续运行

  • 更新判断,在健康检测表中放一个 timestamp 字段,用来表示最后一次执行检测的时间

    1
    UPDATE mysql.health_check SET t_modified=now();

    节点可用性的检测都应该包含主库和备库,为了让主备之间的更新不产生冲突,可以在 mysql.health_check 表上存入多行数据,并用主备的 server_id 做主键,保证主、备库各自的检测命令不会发生冲突


基于位点

主库上位后,从库 B 执行 CHANGE MASTER TO 命令,指定 MASTER_LOG_FILE、MASTER_LOG_POS 表示从新主库 A 的哪个文件的哪个位点开始同步,这个位置就是同步位点,对应主库的文件名和日志偏移量

寻找位点需要找一个稍微往前的,然后再通过判断跳过那些在从库 B 上已经执行过的事务,获取位点方法:

  • 等待新主库 A 把中转日志(relay log)全部同步完成
  • 在 A 上执行 show master status 命令,得到当前 A 上最新的 File 和 Position
  • 取原主库故障的时刻 T,用 mysqlbinlog 工具解析新主库 A 的 File,得到 T 时刻的位点

通常情况下该值并不准确,在切换的过程中会发生错误,所以要先主动跳过这些错误:

  • 切换过程中,可能会重复执行一个事务,所以需要主动跳过所有重复的事务

    1
    2
    SET GLOBAL sql_slave_skip_counter=1;
    START SLAVE;
  • 设置 slave_skip_errors 参数,直接设置跳过指定的错误,保证主从切换的正常进行

    • 1062 错误是插入数据时唯一键冲突
    • 1032 错误是删除数据时找不到行

    该方法针对的是主备切换时,由于找不到精确的同步位点,只能采用这种方法来创建从库和新主库的主备关系。等到主备间的同步关系建立完成并稳定执行一段时间后,还需要把这个参数设置为空,以免真的出现了主从数据不一致也跳过了


基于GTID

GTID

GTID 的全称是 Global Transaction Identifier,全局事务 ID,是一个事务在提交时生成的,是这个事务的唯一标识,组成:

1
GTID=source_id:transaction_id
  • source_id:是一个实例第一次启动时自动生成的,是一个全局唯一的值
  • transaction_id:初始值是 1,每次提交事务的时候分配给这个事务,并加 1,是连续的(区分事务 ID,事务 ID 是在执行时生成)

启动 MySQL 实例时,加上参数 gtid_mode=onenforce_gtid_consistency=on 就可以启动 GTID 模式,每个事务都会和一个 GTID 一一对应,每个 MySQL 实例都维护了一个 GTID 集合,用来存储当前实例执行过的所有事务

GTID 有两种生成方式,使用哪种方式取决于 session 变量 gtid_next:

  • gtid_next=automatic:使用默认值,把 source_id:transaction_id (递增)分配给这个事务,然后加入本实例的 GTID 集合

    1
    @@SESSION.GTID_NEXT = 'source_id:transaction_id';
  • gtid_next=GTID:指定的 GTID 的值,如果该值已经存在于实例的 GTID 集合中,接下来执行的事务会直接被系统忽略;反之就将该值分配给接下来要执行的事务,系统不需要给这个事务生成新的 GTID,也不用加 1

    注意:一个 GTID 只能给一个事务使用,所以执行下一个事务,要把 gtid_next 设置成另外一个 GTID 或者 automatic

业务场景:

  • 主库 X 和从库 Y 执行一条相同的指令后进行事务同步

    1
    INSERT INTO t VALUES(1,1);
  • 当 Y 同步 X 时,会出现主键冲突,导致实例 X 的同步线程停止,解决方法:

    1
    2
    3
    4
    5
    SET gtid_next='(这里是主库 X 的 GTID 值)';
    BEGIN;
    COMMIT;
    SET gtid_next=automatic;
    START SLAVE;

    前三条语句通过提交一个空事务,把 X 的 GTID 加到实例 Y 的 GTID 集合中,实例 Y 就会直接跳过这个事务


切换

在 GTID 模式下,CHANGE MASTER TO 不需要指定日志名和日志偏移量,指定 master_auto_position=1 代表使用 GTID 模式

新主库实例 A 的 GTID 集合记为 set_a,从库实例 B 的 GTID 集合记为 set_b,主备切换逻辑:

  • 实例 B 指定主库 A,基于主备协议建立连接,实例 B 并把 set_b 发给主库 A
  • 实例 A 算出 set_a 与 set_b 的差集,就是所有存在于 set_a 但不存在于 set_b 的 GTID 的集合,判断 A 本地是否包含了这个差集需要的所有 binlog 事务
    • 如果不包含,表示 A 已经把实例 B 需要的 binlog 给删掉了,直接返回错误
    • 如果确认全部包含,A 从自己的 binlog 文件里面,找出第一个不在 set_b 的事务,发给 B
  • 实例 A 之后就从这个事务开始,往后读文件,按顺序取 binlog 发给 B 去执行

参考文章:https://time.geekbang.org/column/article/77427


日志

日志分类

在任何一种数据库中,都会有各种各样的日志,记录着数据库工作的过程,可以帮助数据库管理员追踪数据库曾经发生过的各种事件

MySQL日志主要包括六种:

  1. 重做日志(redo log)
  2. 回滚日志(undo log)
  3. 归档日志(binlog)(二进制日志)
  4. 错误日志(errorlog)
  5. 慢查询日志(slow query log)
  6. 一般查询日志(general log)
  7. 中继日志(relay log)

错误日志

错误日志是 MySQL 中最重要的日志之一,记录了当 mysqld 启动和停止时,以及服务器在运行过程中发生任何严重错误时的相关信息。当数据库出现任何故障导致无法正常使用时,可以首先查看此日志

该日志是默认开启的,默认位置是:/var/log/mysql/error.log

查看指令:

1
SHOW VARIABLES LIKE 'log_error%';

查看日志内容:

1
tail -f /var/log/mysql/error.log

归档日志

基本介绍

归档日志(BINLOG)也叫二进制日志,是因为采用二进制进行存储,记录了所有的 DDL(数据定义语言)语句和 DML(数据操作语言)语句,但不包括数据查询语句,在事务提交前的最后阶段写入

作用:灾难时的数据恢复和 MySQL 的主从复制

归档日志默认情况下是没有开启的,需要在 MySQL 配置文件中开启,并配置 MySQL 日志的格式:

1
2
3
4
5
6
7
cd /etc/mysql
vim my.cnf

# 配置开启binlog日志, 日志的文件前缀为 mysqlbin -----> 生成的文件名如: mysqlbin.000001
log_bin=mysqlbin
# 配置二进制日志的格式
binlog_format=STATEMENT

日志存放位置:配置时给定了文件名但是没有指定路径,日志默认写入MySQL 的数据目录

日志格式:

  • STATEMENT:该日志格式在日志文件中记录的都是 SQL 语句,每一条对数据进行修改的 SQL 都会记录在日志文件中,通过 mysqlbinlog 工具,可以查看到每条语句的文本。主从复制时,从库会将日志解析为原语句,并在从库重新执行一遍

    缺点:可能会导致主备不一致,因为记录的 SQL 在不同的环境中可能选择的索引不同,导致结果不同

  • ROW:该日志格式在日志文件中记录的是每一行的数据变更,而不是记录 SQL 语句。比如执行 SQL 语句 update tb_book set status='1',如果是 STATEMENT,在日志中会记录一行 SQL 语句; 如果是 ROW,由于是对全表进行更新,就是每一行记录都会发生变更,ROW 格式的日志中会记录每一行的数据变更

    缺点:记录的数据比较多,占用很多的存储空间

  • MIXED:这是 MySQL 默认的日志格式,混合了STATEMENT 和 ROW 两种格式,MIXED 格式能尽量利用两种模式的优点,而避开它们的缺点


日志刷盘

事务执行过程中,先将日志写(write)到 binlog cache,事务提交时再把 binlog cache 写(fsync)到 binlog 文件中,一个事务的 binlog 是不能被拆开的,所以不论这个事务多大也要确保一次性写入

事务提交时执行器把 binlog cache 里的完整事务写入到 binlog 中,并清空 binlog cache

write 和 fsync 的时机由参数 sync_binlog 控制的:

  • sync_binlog=0:表示每次提交事务都只 write,不 fsync
  • sync_binlog=1:表示每次提交事务都会执行 fsync
  • sync_binlog=N(N>1):表示每次提交事务都 write,但累积 N 个事务后才 fsync,但是如果主机发生异常重启,会丢失最近 N 个事务的 binlog 日志

日志读取

日志文件存储位置:/var/lib/mysql

由于日志以二进制方式存储,不能直接读取,需要用 mysqlbinlog 工具来查看,语法如下:

1
mysqlbinlog log-file;

查看 STATEMENT 格式日志:

  • 执行插入语句:

    1
    INSERT INTO tb_book VALUES(NULL,'Lucene','2088-05-01','0');
  • cd /var/lib/mysql

    1
    2
    -rw-r-----  1 mysql mysql      177 5月  23 21:08 mysqlbin.000001
    -rw-r----- 1 mysql mysql 18 5月 23 21:04 mysqlbin.index

    mysqlbin.index:该文件是日志索引文件 , 记录日志的文件名;

    mysqlbing.000001:日志文件

  • 查看日志内容:

    1
    mysqlbinlog mysqlbing.000001;

    日志结尾有 COMMIT

查看 ROW 格式日志:

  • 修改配置:

    1
    2
    # 配置二进制日志的格式
    binlog_format=ROW
  • 插入数据:

    1
    INSERT INTO tb_book VALUES(NULL,'SpringCloud实战','2088-05-05','0');
  • 查看日志内容:日志格式 ROW,直接查看数据是乱码,可以在 mysqlbinlog 后面加上参数 -vv

    1
    mysqlbinlog -vv mysqlbin.000002


日志删除

对于比较繁忙的系统,生成日志量大,这些日志如果长时间不清除,将会占用大量的磁盘空间,需要删除日志

  • Reset Master 指令删除全部 binlog 日志,删除之后,日志编号将从 xxxx.000001重新开始

    1
    Reset Master	-- MySQL指令
  • 执行指令 PURGE MASTER LOGS TO 'mysqlbin.***,该命令将删除 *** 编号之前的所有日志

  • 执行指令 PURGE MASTER LOGS BEFORE 'yyyy-mm-dd hh:mm:ss' ,该命令将删除日志为 yyyy-mm-dd hh:mm:ss 之前产生的日志

  • 设置参数 --expire_logs_days=#,此参数的含义是设置日志的过期天数,过了指定的天数后日志将会被自动删除,这样做有利于减少管理日志的工作量,配置 my.cnf 文件:

    1
    2
    3
    log_bin=mysqlbin
    binlog_format=ROW
    --expire_logs_days=3

数据恢复

误删库或者表时,需要根据 binlog 进行数据恢复

一般情况下数据库有定时的全量备份,假如每天 0 点定时备份,12 点误删了库,恢复流程:

  • 取最近一次全量备份,用备份恢复出一个临时库
  • 从日志文件中取出凌晨 0 点之后的日志
  • 把除了误删除数据的语句外日志,全部应用到临时库

跳过误删除语句日志的方法:

  • 如果原实例没有使用 GTID 模式,只能在应用到包含 12 点的 binlog 文件的时候,先用 –stop-position 参数执行到误操作之前的日志,然后再用 –start-position 从误操作之后的日志继续执行
  • 如果实例使用了 GTID 模式,假设误操作命令的 GTID 是 gtid1,那么只需要提交一个空事务先将这个 GTID 加到临时实例的 GTID 集合,之后按顺序执行 binlog 的时就会自动跳过误操作的语句

查询日志

查询日志中记录了客户端的所有操作语句,而二进制日志不包含查询数据的 SQL 语句

默认情况下,查询日志是未开启的。如果需要开启查询日志,配置 my.cnf:

1
2
3
4
# 该选项用来开启查询日志,可选值0或者1,0代表关闭,1代表开启 
general_log=1
# 设置日志的文件名,如果没有指定,默认的文件名为host_name.log,存放在/var/lib/mysql
general_log_file=mysql_query.log

配置完毕之后,在数据库执行以下操作:

1
2
3
4
SELECT * FROM tb_book;
SELECT * FROM tb_book WHERE id = 1;
UPDATE tb_book SET name = 'lucene入门指南' WHERE id = 5;
SELECT * FROM tb_book WHERE id < 8

执行完毕之后, 再次来查询日志文件:


慢日志

慢查询日志记录所有执行时间超过 long_query_time 并且扫描记录数不小于 min_examined_row_limit 的所有的 SQL 语句的日志long_query_time 默认为 10 秒,最小为 0, 精度到微秒

慢查询日志默认是关闭的,可以通过两个参数来控制慢查询日志,配置文件 /etc/mysql/my.cnf

1
2
3
4
5
6
7
8
# 该参数用来控制慢查询日志是否开启,可选值0或者1,0代表关闭,1代表开启 
slow_query_log=1

# 该参数用来指定慢查询日志的文件名,存放在 /var/lib/mysql
slow_query_log_file=slow_query.log

# 该选项用来配置查询的时间限制,超过这个时间将认为值慢查询,将需要进行日志记录,默认10s
long_query_time=10

日志读取:

  • 直接通过 cat 指令查询该日志文件:

    1
    cat slow_query.log

  • 如果慢查询日志内容很多,直接查看文件比较繁琐,可以借助 mysql 自带的 mysqldumpslow 工具对慢查询日志进行分类汇总:

    1
    mysqldumpslow slow_query.log


范式

第一范式

建立科学的,规范的数据表就需要满足一些规则来优化数据的设计和存储,这些规则就称为范式

1NF:数据库表的每一列都是不可分割的原子数据项,不能是集合、数组等非原子数据项。即表中的某个列有多个值时,必须拆分为不同的列。简而言之,第一范式每一列不可再拆分,称为原子性

基本表:

第一范式表:


第二范式

2NF:在满足第一范式的基础上,非主属性完全依赖于主码(主关键字、主键),消除非主属性对主码的部分函数依赖。简而言之,表中的每一个字段 (所有列)都完全依赖于主键,记录的唯一性

作用:遵守第二范式减少数据冗余,通过主键区分相同数据。

  1. 函数依赖:A → B,如果通过 A 属性(属性组)的值,可以确定唯一 B 属性的值,则称 B 依赖于 A
    • 学号 → 姓名;(学号,课程名称) → 分数
  2. 完全函数依赖:A → B,如果A是一个属性组,则 B 属性值的确定需要依赖于 A 属性组的所有属性值
    • (学号,课程名称) → 分数
  3. 部分函数依赖:A → B,如果 A 是一个属性组,则 B 属性值的确定只需要依赖于 A 属性组的某些属性值
    • (学号,课程名称) → 姓名
  4. 传递函数依赖:A → B,B → C,如果通过A属性(属性组)的值,可以确定唯一 B 属性的值,在通过 B 属性(属性组)的值,可以确定唯一 C 属性的值,则称 C 传递函数依赖于 A
    • 学号 → 系名,系名 → 系主任
  5. 码:如果在一张表中,一个属性或属性组,被其他所有属性所完全依赖,则称这个属性(属性组)为该表的码
    • 该表中的码:(学号,课程名称)
    • 主属性:码属性组中的所有属性
    • 非主属性:除码属性组以外的属性


第三范式

3NF:在满足第二范式的基础上,表中的任何属性不依赖于其它非主属性,消除传递依赖。简而言之,非主键都直接依赖于主键,而不是通过其它的键来间接依赖于主键

作用:可以通过主键 id 区分相同数据,修改数据的时候只需要修改一张表(方便修改),反之需要修改多表。


总结


Redis

NoSQL

概述

NoSQL(Not-Only SQL):泛指非关系型的数据库,作为关系型数据库的补充

MySQL 支持 ACID 特性,保证可靠性和持久性,读取性能不高,因此需要缓存的来减缓数据库的访问压力

作用:应对基于海量用户和海量数据前提下的数据处理问题

特征:

  • 可扩容,可伸缩,SQL 数据关系过于复杂,Nosql 不存关系,只存数据
  • 大数据量下高性能,数据不存取在磁盘 IO,存取在内存
  • 灵活的数据模型,设计了一些数据存储格式,能保证效率上的提高
  • 高可用,集群

常见的 NoSQL:Redis、memcache、HBase、MongoDB

参考书籍:https://book.douban.com/subject/25900156/

参考视频:https://www.bilibili.com/video/BV1CJ411m7Gc


Redis

Redis (REmote DIctionary Server) :用 C 语言开发的一个开源的高性能键值对(key-value)数据库

特征:

  • 数据间没有必然的关联关系,不存关系,只存数据
  • 数据存储在内存,存取速度快,解决了磁盘 IO 速度慢的问题
  • 内部采用单线程机制进行工作
  • 高性能,官方测试数据,50 个并发执行 100000 个请求,读的速度是 110000 次/s,写的速度是 81000 次/s
  • 多数据类型支持
    • 字符串类型:string(String)
    • 列表类型:list(LinkedList)
    • 散列类型:hash(HashMap)
    • 集合类型:set(HashSet)
    • 有序集合类型:zset/sorted_set(TreeSet)
  • 支持持久化,可以进行数据灾难恢复

安装启动

安装:

  • Redis 5.0 被包含在默认的 Ubuntu 20.04 软件源中

    1
    2
    sudo apt update
    sudo apt install redis-server
  • 检查 Redis 状态

    1
    sudo systemctl status redis-server

启动:

  • 启动服务器——参数启动

    1
    2
    redis-server [--port port]
    #redis-server --port 6379
  • 启动服务器——配置文件启动

    1
    2
    redis-server config_file_name
    #redis-server /etc/redis/conf/redis-6397.conf
  • 启动客户端:

    1
    2
    redis-cli [-h host] [-p port]
    #redis-cli -h 192.168.2.185 -p 6397

    注意:服务器启动指定端口使用的是–port,客户端启动指定端口使用的是-p


基本配置

系统目录

  1. 创建文件结构

    创建配置文件存储目录

    1
    mkdir conf

    创建服务器文件存储目录(包含日志、数据、临时配置文件等)

    1
    mkdir data
  2. 创建配置文件副本放入 conf 目录,Ubuntu 系统配置文件 redis.conf 在目录 /etc/redis

    1
    cat redis.conf | grep -v "#" | grep -v "^$" -> /conf/redis-6379.conf

    去除配置文件的注释和空格,输出到新的文件,命令方式采用 redis-port.conf


服务器

  • 设置服务器以守护进程的方式运行,关闭后服务器控制台中将打印服务器运行信息(同日志内容相同):

    1
    daemonize yes|no
  • 绑定主机地址,绑定本地IP地址,否则SSH无法访问:

    1
    bind ip
  • 设置服务器端口:

    1
    port port
  • 设置服务器文件保存地址:

    1
    dir path
  • 设置数据库的数量:

    1
    databases 16
  • 多服务器快捷配置:

    导入并加载指定配置文件信息,用于快速创建 redis 公共配置较多的 redis 实例配置文件,便于维护

    1
    include /path/conf_name.conf

客户端

  • 服务器允许客户端连接最大数量,默认 0,表示无限制,当客户端连接到达上限后,Redis 会拒绝新的连接:

    1
    maxclients count
  • 客户端闲置等待最大时长,达到最大值后关闭对应连接,如需关闭该功能,设置为 0:

    1
    timeout seconds

日志配置

设置日志记录

  • 设置服务器以指定日志记录级别

    1
    loglevel debug|verbose|notice|warning
  • 日志记录文件名

    1
    logfile filename

注意:日志级别开发期设置为 verbose 即可,生产环境中配置为 notice,简化日志输出量,降低写日志 IO 的频度

配置文件:

1
2
3
4
5
6
7
bind 192.168.2.185
port 6379
#timeout 0
daemonize no
logfile /etc/redis/data/redis-6379.log
dir /etc/redis/data
dbfilename "dump-6379.rdb"

基本指令

帮助信息:

  • 获取命令帮助文档

    1
    2
    help [command]
    #help set
  • 获取组中所有命令信息名称

    1
    2
    help [@group-name]
    #help @string

退出服务

  • 退出客户端:

    1
    2
    quit
    exit
  • 退出客户端服务器快捷键:

    1
    Ctrl+C

数据库

服务器

Redis 服务器将所有数据库保存在服务器状态 redisServer 结构的 db 数组中,数组的每一项都是 redisDb 结构,代表一个数据库,每个数据库之间相互独立,**共用 **Redis 内存,不区分大小。在初始化服务器时,根据 dbnum 属性决定创建数据库的数量,该属性由服务器配置的 database 选项决定,默认 16

1
2
3
4
5
6
7
struct redisServer {
// 保存服务器所有的数据库
redisDB *db;

// 服务器数据库的数量
int dbnum;
};

在服务器内部,客户端状态 redisClient 结构的 db 属性记录了目标数据库,是一个指向 redisDb 结构的指针

1
2
3
4
struct redisClient {
// 记录客户端正在使用的数据库,指向 redisServer.db 数组中的某一个 db
redisDB *db;
};

每个 Redis 客户端都有目标数据库,执行数据库读写命令时目标数据库就会成为这些命令的操作对象,默认情况下 Redis 客户端的目标数据库为 0 号数据库,客户端可以执行 SELECT 命令切换目标数据库,原理是通过修改 redisClient.db 指针指向服务器中不同数据库

命令操作:

1
2
3
4
select index	#切换数据库,index从0-15取值
move key db #数据移动到指定数据库,db是数据库编号
ping #测试数据库是否连接正常,返回PONG
echo message #控制台输出信息

Redis 没有可以返回客户端目标数据库的命令,但是 redis-cli 客户端旁边会提示当前所使用的目标数据库

1
2
3
redis> SELECT 1 
OK
redis[1]>

键空间

key space

Redis 是一个键值对(key-value pair)数据库服务器,每个数据库都由一个 redisDb 结构表示,redisDb.dict 字典中保存了数据库的所有键值对,将这个字典称为键空间(key space)

1
2
3
4
typedef struct redisDB {
// 数据库键空间,保存所有键值对
dict *dict
} redisDB;

键空间和用户所见的数据库是直接对应的:

  • 键空间的键就是数据库的键,每个键都是一个字符串对象
  • 键空间的值就是数据库的值,每个值可以是任意一种 Redis 对象

当使用 Redis 命令对数据库进行读写时,服务器不仅会对键空间执行指定的读写操作,还会进行一些维护操作

  • 在读取一个键后(读操作和写操作都要对键进行读取),服务器会根据键是否存在来更新服务器的键空间命中 hit 次数或键空间不命中 miss 次数,这两个值可以在 INFO stats 命令的 keyspace_hits 属性和 keyspace_misses 属性中查看
  • 更新键的 LRU(最后使用)时间,该值可以用于计算键的闲置时间,使用 OBJECT idletime key 查看键 key 的闲置时间
  • 如果在读取一个键时发现该键已经过期,服务器会先删除过期键,再执行其他操作
  • 如果客户端使用 WATCH 命令监视了某个键,那么服务器在对被监视的键进行修改之后,会将这个键标记为脏(dirty),从而让事务注意到这个键已经被修改过
  • 服务器每次修改一个键之后,都会对 dirty 键计数器的值增1,该计数器会触发服务器的持久化以及复制操作
  • 如果服务器开启了数据库通知功能,那么在对键进行修改之后,服务器将按配置发送相应的数据库通知

读写指令

常见键操作指令:

  • 增加指令

    1
    2
    3
    4
    5
    6
    7
      set key value				#添加一个字符串类型的键值对

    * 删除指令

    ```sh
    del key #删除指定key
    unlink key #非阻塞删除key,真正的删除会在后续异步操作
  • 更新指令

    1
    2
    rename key newkey			#改名
    renamenx key newkey #改名

    值得更新需要参看具体得 Redis 对象得操作方式,比如字符串对象执行 SET key value 就可以完成修改

  • 查询指令

    1
    2
    3
    exists key					#获取key是否存在
    randomkey #随机返回一个键
    keys pattern #查询key

    KEYS 命令需要遍历存储的键值对,操作延时高,一般不被建议用于生产环境中

    查询模式规则:* 匹配任意数量的任意符号、? 配合一个任意符号、[] 匹配一个指定符号

    1
    2
    3
    4
    5
    6
    keys *						#查询所有key
    keys aa* #查询所有以aa开头
    keys *bb #查询所有以bb结尾
    keys ??cc #查询所有前面两个字符任意,后面以cc结尾
    keys user:? #查询所有以user:开头,最后一个字符任意
    keys u[st]er:1 #查询所有以u开头,以er:1结尾,中间包含一个字母,s或t
  • 其他指令

    1
    2
    3
    4
    type key					#获取key的类型
    dbsize #获取当前数据库的数据总量,即key的个数
    flushdb #清除当前数据库的所有数据(慎用)
    flushall #清除所有数据(慎用)

    在执行 FLUSHDB 这样的危险命令之前,最好先执行一个 SELECT 命令,保证当前所操作的数据库是目标数据库


时效设置

客户端可以以秒或毫秒的精度为数据库中的某个键设置生存时间(TimeTo Live, TTL),在经过指定时间之后,服务器就会自动删除生存时间为 0 的键;也可以以 UNIX 时间戳的方式设置过期时间(expire time),当键的过期时间到达,服务器会自动删除这个键

1
2
3
4
expire key seconds			#为指定key设置生存时间,单位为秒
pexpire key milliseconds #为指定key设置生存时间,单位为毫秒
expireat key timestamp #为指定key设置过期时间,单位为时间戳
pexpireat key mil-timestamp #为指定key设置过期时间,单位为毫秒时间戳
  • 实际上 EXPIRE、EXPIRE、EXPIREAT 三个命令底层都是转换为 PEXPIREAT 命令来实现的
  • SETEX 命令可以在设置一个字符串键的同时为键设置过期时间,但是该命令是一个类型限定命令

redisDb 结构的 expires 字典保存了数据库中所有键的过期时间,字典称为过期字典:

  • 键是一个指针,指向键空间中的某个键对象(复用键空间的对象,不会产生内存浪费)
  • 值是一个 long long 类型的整数,保存了键的过期时间,是一个毫秒精度的 UNIX 时间戳
1
2
3
4
typedef struct redisDB {
// 过期字典,保存所有键的过期时间
dict *expires
} redisDB;

客户端执行 PEXPIREAT 命令,服务器会在数据库的过期字典中关联给定的数据库键和过期时间:

1
2
3
4
5
6
7
8
9
10
def PEXPIREAT(key, expire_time_in_ms):
# 如果给定的键不存在于键空间,那么不能设置过期时间
if key not in redisDb.dict:
return 0

# 在过期字典中关联键和过期时间
redisDB.expires[key] = expire_time_in_ms

# 过期时间设置成功
return 1

时效状态

TTL 和 PTTL 命令通过计算键的过期时间和当前时间之间的差,返回这个键的剩余生存时间

  • 返回正数代表该数据在内存中还能存活的时间
  • 返回 -1 代表永久性,返回 -2 代表键不存在
1
2
ttl key			#获取key的剩余时间,每次获取会自动变化(减小),类似于倒计时
pttl key #获取key的剩余时间,单位是毫秒,每次获取会自动变化(减小)

PERSIST 是 PEXPIREAT 命令的反操作,在过期字典中查找给定的键,并解除键和值(过期时间)在过期字典中的关联

1
persist key		#切换key从时效性转换为永久性

Redis 通过过期字典可以检查一个给定键是否过期:

  • 检查给定键是否存在于过期字典:如果存在,那么取得键的过期时间
  • 检查当前 UNIX 时间戳是否大于键的过期时间:如果是那么键已经过期,否则键未过期

补充:AOF、RDB 和复制功能对过期键的处理

  • RDB :
    • 生成 RDB 文件,程序会对数据库中的键进行检查,已过期的键不会被保存到新创建的 RDB 文件中
    • 载入 RDB 文件,如果服务器以主服务器模式运行,那么在载入时会对键进行检查,过期键会被忽略;如果服务器以从服务器模式运行,会载入所有键,包括过期键,但是主从服务器进行数据同步时就会删除这些键
  • AOF:
    • 写入 AOF 文件,如果数据库中的某个键已经过期,但还没有被删除,那么 AOF 文件不会因为这个过期键而产生任何影响;当该过期键被删除,程序会向 AOF 文件追加一条 DEL 命令,显式的删除该键
    • AOF 重写,会对数据库中的键进行检查,忽略已经过期的键
  • 复制:当服务器运行在复制模式下时,从服务器的过期键删除动作由主服务器控制
    • 主服务器在删除一个过期键之后,会显式地向所有从服务器发送一个 DEL 命令,告知从服务器删除这个过期键
    • 从服务器在执行客户端发送的读命令时,即使碰到过期键也不会将过期键删除,会当作未过期键处理,只有在接到主服务器发来的 DEL 命令之后,才会删除过期键(数据不一致)

过期删除

删除策略

删除策略就是针对已过期数据的处理策略,已过期的数据不一定被立即删除,在不同的场景下使用不同的删除方式会有不同效果,在内存占用与 CPU 占用之间寻找一种平衡,顾此失彼都会造成整体 Redis 性能的下降,甚至引发服务器宕机或内存泄露

针对过期数据有三种删除策略:

  • 定时删除
  • 惰性删除
  • 定期删除

Redis 采用惰性删除和定期删除策略的结合使用


定时删除

在设置键的过期时间的同时,创建一个定时器(timer),让定时器在键的过期时间到达时,立即执行对键的删除操作

  • 优点:节约内存,到时就删除,快速释放掉不必要的内存占用
  • 缺点:对 CPU 不友好,无论 CPU 此时负载多高均占用 CPU,会影响 Redis 服务器响应时间和指令吞吐量
  • 总结:用处理器性能换取存储空间(拿时间换空间)

创建一个定时器需要用到 Redis 服务器中的时间事件,而时间事件的实现方式是无序链表,查找一个事件的时间复杂度为 O(N),并不能高效地处理大量时间事件,所以采用这种方式并不现实


惰性删除

数据到达过期时间不做处理,等下次访问到该数据时执行 expireIfNeeded() 判断:

  • 如果输入键已经过期,那么 expireIfNeeded 函数将输入键从数据库中删除,接着访问就会返回空
  • 如果输入键未过期,那么 expireIfNeeded 函数不做动作

所有的 Redis 读写命令在执行前都会调用 expireIfNeeded 函数进行检查,该函数就像一个过滤器,在命令真正执行之前过滤掉过期键

惰性删除的特点:

  • 优点:节约 CPU 性能,删除的目标仅限于当前处理的键,不会在删除其他无关的过期键上花费任何 CPU 时间
  • 缺点:内存压力很大,出现长期占用内存的数据,如果过期键永远不被访问,这种情况相当于内存泄漏
  • 总结:用存储空间换取处理器性能(拿空间换时间)

定期删除

定期删除策略是每隔一段时间执行一次删除过期键操作,并通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响

  • 如果删除操作执行得太频繁,或者执行时间太长,就会退化成定时删除策略,将 CPU 时间过多地消耗在删除过期键上
  • 如果删除操作执行得太少,或者执行时间太短,定期删除策略又会和惰性删除策略一样,出现浪费内存的情况

定期删除是周期性轮询 Redis 库中的时效性数据,从过期字典中随机抽取一部分键检查,利用过期数据占比的方式控制删除频度

  • Redis 启动服务器初始化时,读取配置 server.hz 的值,默认为 10,执行指令 info server 可以查看,每秒钟执行 server.hz 次 serverCron() → activeExpireCycle()

  • activeExpireCycle() 对某个数据库中的每个 expires 进行检测,工作模式:

    • 轮询每个数据库,从数据库中取出一定数量的随机键进行检查,并删除其中的过期键

    • 全局变量 current_db 用于记录 activeExpireCycle() 的检查进度(哪一个数据库),下一次调用时接着该进度处理

    • 随着函数的不断执行,服务器中的所有数据库都会被检查一遍,这时将 current_db 重置为 0,然后再次开始新一轮的检查

定期删除特点:

  • CPU 性能占用设置有峰值,检测频度可自定义设置
  • 内存压力不是很大,长期占用内存的冷数据会被持续清理
  • 周期性抽查存储空间(随机抽查,重点抽查)

数据淘汰

逐出算法

数据淘汰策略:当新数据进入 Redis 时,在执行每一个命令前,会调用 freeMemoryIfNeeded() 检测内存是否充足。如果内存不满足新加入数据的最低存储要求,Redis 要临时删除一些数据为当前指令清理存储空间,清理数据的策略称为逐出算法

逐出数据的过程不是 100% 能够清理出足够的可使用的内存空间,如果不成功则反复执行,当对所有数据尝试完毕,如不能达到内存清理的要求,出现 Redis 内存打满异常

1
(error) OOM command not allowed when used memory >'maxmemory'

策略配置

Redis 如果不设置最大内存大小或者设置最大内存大小为 0,在 64 位操作系统下不限制内存大小,在 32 位操作系统默认为 3GB 内存,一般推荐设置 Redis 内存为最大物理内存的四分之三

内存配置方式:

  • 通过修改文件配置(永久生效):修改配置文件 maxmemory 字段,单位为字节

  • 通过命令修改(重启失效):

    • config set maxmemory 104857600:设置 Redis 最大占用内存为 100MB

    • config get maxmemory:获取 Redis 最大占用内存

    • info :可以查看 Redis 内存使用情况,used_memory_human 字段表示实际已经占用的内存,maxmemory 表示最大占用内存

影响数据淘汰的相关配置如下,配置 conf 文件:

  • 每次选取待删除数据的个数,采用随机获取数据的方式作为待检测删除数据,防止全库扫描,导致严重的性能消耗,降低读写性能

    1
    maxmemory-samples count
  • 达到最大内存后的,对被挑选出来的数据进行删除的策略

    1
    maxmemory-policy policy

    数据删除的策略 policy:3 类 8 种

    第一类:检测易失数据(可能会过期的数据集 server.db[i].expires):

    1
    2
    3
    4
    volatile-lru	# 对设置了过期时间的 key 选择最近最久未使用使用的数据淘汰
    volatile-lfu # 对设置了过期时间的 key 选择最近使用次数最少的数据淘汰
    volatile-ttl # 对设置了过期时间的 key 选择将要过期的数据淘汰
    volatile-random # 对设置了过期时间的 key 选择任意数据淘汰

    第二类:检测全库数据(所有数据集 server.db[i].dict ):

    1
    2
    3
    allkeys-lru		# 对所有 key 选择最近最少使用的数据淘汰
    allkeLyRs-lfu # 对所有 key 选择最近使用次数最少的数据淘汰
    allkeys-random # 对所有 key 选择任意数据淘汰,相当于随机

    第三类:放弃数据驱逐

    1
    no-enviction	#禁止驱逐数据(redis4.0中默认策略),会引发OOM(Out Of Memory)

数据淘汰策略配置依据:使用 INFO 命令输出监控信息,查询缓存 hit 和 miss 的次数,根据需求调优 Redis 配置


排序机制

基本介绍

Redis 的 SORT 命令可以对列表键、集合键或者有序集合键的值进行排序,并不更改集合中的数据位置,只是查询

1
2
SORT key [ASC/DESC]			#对key中数据排序,默认对数字排序,并不更改集合中的数据位置,只是查询
SORT key ALPHA #对key中字母排序,按照字典序

SORT

SORT <key> 命令可以对一个包含数字值的键 key 进行排序

假设 RPUSH numbers 3 1 2,执行 SORT numbers 的详细步骤:

  • 创建一个和 key 列表长度相同的数组,数组每项都是 redisSortObject 结构

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    typedef struct redisSortObject {
    // 被排序键的值
    robj *obj;

    // 权重
    union {
    // 排序数字值时使用
    double score;
    // 排序带有 BY 选项的字符串
    robj *cmpobj;
    } u;
    }
  • 遍历数组,将各个数组项的 obj 指针分别指向 numbers 列表的各个项

  • 遍历数组,将 obj 指针所指向的列表项转换成一个 double 类型的浮点数,并将浮点数保存在对应数组项的 u.score 属性里

  • 根据数组项 u.score 属性的值,对数组进行数字值排序,排序后的数组项按 u.score 属性的值从小到大排列

  • 遍历数组,将各个数组项的 obj 指针所指向的值作为排序结果返回给客户端,程序首先访问数组的索引 0,依次向后访问

对于 SORT key [ASC/DESC] 函数:

  • 在执行升序排序时,排序算法使用的对比函数产生升序对比结果
  • 在执行降序排序时,排序算法使用的对比函数产生降序对比结果

BY

SORT 命令默认使用被排序键中包含的元素作为排序的权重,元素本身决定了元素在排序之后所处的位置,通过使用 BY 选项,SORT 命令可以指定某些字符串键,或者某个哈希键所包含的某些域(field)来作为元素的权重,对一个键进行排序

1
2
SORT <key> BY <pattern>			# 数值
SORT <key> BY <pattern> ALPHA # 字符
1
2
3
4
5
6
redis> SADD fruits "apple" "banana" "cherry" 
(integer) 3
redis> SORT fruits ALPHA
1) "apple"
2) "banana"
3) "cherry"
1
2
3
4
5
6
7
redis> MSET apple-price 8 banana-price 5.5 cherry-price 7 
OK
# 使用水果的价钱进行排序
redis> SORT fruits BY *-price
1) "banana"
2) "cherry"
3) "apple"

实现原理:排序时的 u.score 属性就会被设置为对应的权重


LIMIT

SORT 命令默认会将排序后的所有元素都返回给客户端,通过 LIMIT 选项可以让 SORT 命令只返回其中一部分已排序的元素

1
LIMIT <offset> <count>
  • offset 参数表示要跳过的已排序元素数量
  • count 参数表示跳过给定数量的元素后,要返回的已排序元素数量
1
2
3
4
5
# 对应 a b c d e f  g
redis> SORT alphabet ALPHA LIMIT 2 3
1) "c"
2) "d"
3) "e"

实现原理:在排序后的 redisSortObject 结构数组中,将指针移动到数组的索引 2 上,依次访问 array[2]、array[3]、array[4] 这 3 个数组项,并将数组项的 obj 指针所指向的元素返回给客户端


GET

SORT 命令默认在对键进行排序后,返回被排序键本身所包含的元素,通过使用 GET 选项, 可以在对键进行排序后,根据被排序的元素以及 GET 选项所指定的模式,查找并返回某些键的值

1
SORT <key> GET <pattern>
1
2
3
4
5
6
7
8
redis> SADD students "tom" "jack" "sea"
#设置全名
redis> SET tom-name "Tom Li"
OK
redis> SET jack-name "Jack Wang"
OK
redis> SET sea-name "Sea Zhang"
OK
1
2
3
4
redis> SORT students ALPHA GET *-name
1) "Jack Wang"
2) "Sea Zhang"
3) "Tom Li"

实现原理:对 students 进行排序后,对于 jack 元素和 *-name 模式,查找程序返回键 jack-name,然后获取 jack-name 键对应的值


STORE

SORT 命令默认只向客户端返回排序结果,而不保存排序结果,通过使用 STORE 选项可以将排序结果保存在指定的键里面

1
SORT <key> STORE <sort_key>
1
2
3
4
redis> SADD students "tom" "jack" "sea"
(integer) 3
redis> SORT students ALPHA STORE sorted_students
(integer) 3

实现原理:排序后,检查 sorted_students 键是否存在,如果存在就删除该键,设置 sorted_students 为空白的列表键,遍历排序数组将元素依次放入


执行顺序

调用 SORT 命令,除了 GET 选项之外,改变其他选项的摆放顺序并不会影响命令执行选项的顺序

1
SORT <key> ALPHA [ASC/DESC] BY <by-pattern> LIMIT <offset> <count> GET <get-pattern> STORE <store_key>

执行顺序:

  • 排序:命令会使用 ALPHA 、ASC 或 DESC、BY 这几个选项,对输入键进行排序,并得到一个排序结果集
  • 限制排序结果集的长度:使用 LIMIT 选项,对排序结果集的长度进行限制
  • 获取外部键:根据排序结果集中的元素以及 GET 选项指定的模式,查找并获取指定键的值,并用这些值来作为新的排序结果集
  • 保存排序结果集:使用 STORE 选项,将排序结果集保存到指定的键上面去
  • 向客户端返回排序结果集:最后一步命令遍历排序结果集,并依次向客户端返回排序结果集中的元素

通知机制

数据库通知是可以让客户端通过订阅给定的频道或者模式,来获知数据库中键的变化,以及数据库中命令的执行情况

  • 关注某个键执行了什么命令的通知称为键空间通知(key-space notification)
  • 关注某个命令被什么键执行的通知称为键事件通知(key-event notification)

图示订阅 0 号数据库 message 键:

服务器配置的 notify-keyspace-events 选项决定了服务器所发送通知的类型

  • AKE 代表服务器发送所有类型的键空间通知和键事件通知
  • AK 代表服务器发送所有类型的键空间通知
  • AE 代表服务器发送所有类型的键事件通知
  • K$ 代表服务器只发送和字符串键有关的键空间通知
  • EL 代表服务器只发送和列表键有关的键事件通知
  • …..

发送数据库通知的功能是由 notifyKeyspaceEvent 函数实现的:

  • 如果给定的通知类型 type 不是服务器允许发送的通知类型,那么函数会直接返回
  • 如果给定的通知是服务器允许发送的通知
    • 检测服务器是否允许发送键空间通知,允许就会构建并发送事件通知
    • 检测服务器是否允许发送键事件通知,允许就会构建并发送事件通知

体系架构

事件驱动

基本介绍

Redis 服务器是一个事件驱动程序,服务器需要处理两类事件

  • 文件事件 (file event):服务器通过套接字与客户端(或其他 Redis 服务器)进行连接,而文件事件就是服务器对套接字操作的抽象。服务器与客户端的通信会产生相应的文件事件,服务器通过监听并处理这些事件完成一系列网络通信操作
  • 时间事件 (time event):Redis 服务器中的一些操作(比如 serverCron 函数)需要在指定时间执行,而时间事件就是服务器对这类定时操作的抽象

文件事件

基本组成

Redis 基于 Reactor 模式开发了网络事件处理器,这个处理器被称为文件事件处理器 (file event handler)

  • 使用 I/O 多路复用 (multiplexing) 程序来同时监听多个套接字,并根据套接字执行的任务来为套接字关联不同的事件处理器

  • 当被监听的套接字准备好执行连接应答 (accept)、 读取 (read)、 写入 (write)、 关闭 (close) 等操作时,与操作相对应的文件事件就会产生,这时文件事件分派器会调用套接字关联好的事件处理器来处理事件

文件事件处理器以单线程方式运行,但通过使用 I/O 多路复用程序来监听多个套接字, 既实现了高性能的网络通信模型,又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接,保持了 Redis 内部单线程设计的简单性

文件事件处理器的组成结构:

尽管多个文件事件可能会并发出现,但是 I/O 多路复用程序将所有产生事件的套接字处理请求放入一个单线程的执行队列中,通过队列有序、同步的向文件事件分派器传送套接字,上一个套接字产生的事件处理完后,才会继续向分派器传送下一个

Redis 单线程也能高效的原因:

  • 纯内存操作
  • 核心是基于非阻塞的 IO 多路复用机制,单线程可以高效处理多个请求
  • 底层使用 C 语言实现,C 语言实现的程序距离操作系统更近,执行速度相对会更快
  • 单线程同时也避免了多线程的上下文频繁切换问题,预防了多线程可能产生的竞争问题

多路复用

Redis 的 I/O 多路复用程序的所有功能都是通过包装常见的 select 、epoll、 evport 和 kqueue 这些函数库来实现的,Redis 在 I/O 多路复用程序的实现源码中用 #include 宏定义了相应的规则,编译时自动选择系统中性能最高的多路复用函数来作为底层实现

I/O 多路复用程序监听多个套接字的 AE_READABLE 事件和 AE_WRITABLE 事件,这两类事件和套接字操作之间的对应关系如下:

  • 当套接字变得可读时(客户端对套接字执行 write 操作或者 close 操作),或者有新的可应答(acceptable)套接字出现时(客户端对服务器的监听套接字执行 connect 连接操作),套接字产生 AE_READABLE 事件
  • 当套接字变得可写时(客户端对套接字执行 read 操作,对于服务器来说就是可以写了),套接字产生 AE_WRITABLE 事件

I/O 多路复用程序允许服务器同时监听套接字的 AE_READABLE 和 AE_WRITABLE 事件, 如果一个套接字同时产生了这两种事件,那么文件事件分派器会优先处理 AE_READABLE 事件, 等 AE_READABLE 事件处理完之后才处理 AE_WRITABLE 事件


处理器

Redis 为文件事件编写了多个处理器,这些事件处理器分别用于实现不同的网络通信需求:

  • 连接应答处理器,用于对连接服务器的各个客户端进行应答,Redis 服务器初始化时将该处理器与 AE_READABLE 事件关联
  • 命令请求处理器,用于接收客户端传来的命令请求,执行套接字的读入操作,与 AE_READABLE 事件关联
  • 命令回复处理器,用于向客户端返回命令的执行结果,执行套接字的写入操作,与 AE_WRITABLE 事件关联
  • 复制处理器,当主服务器和从服务器进行复制操作时,主从服务器都需要关联该处理器

Redis 客户端与服务器进行连接并发送命令的整个过程:

  • Redis 服务器正在运作监听套接字的 AE_READABLE 事件,关联连接应答处理器
  • 当 Redis 客户端向服务器发起连接,监听套接字将产生 AE_READABLE 事件,触发连接应答处理器执行,对客户端的连接请求进行应答,创建客户端套接字以及客户端状态,并将客户端套接字的 AE_READABLE 事件与命令请求处理器进行关联
  • 客户端向服务器发送命令请求,客户端套接字产生 AE_READABLE 事件,引发命令请求处理器执行,读取客户端的命令内容传给相关程序去执行
  • 执行命令会产生相应的命令回复,为了将这些命令回复传送回客户端,服务器会将客户端套接字的 AE_WRITABLE 事件与命令回复处理器进行关联
  • 当客户端尝试读取命令回复时,客户端套接字产生 AE_WRITABLE 事件,触发命令回复处理器执行,在命令回复全部写入套接字后,服务器就会解除客户端套接字的 AE_WRITABLE 事件与命令回复处理器之间的关联

时间事件

Redis 的时间事件分为以下两类:

  • 定时事件:在指定的时间之后执行一次(Redis 中暂时未使用)
  • 周期事件:每隔指定时间就执行一次

一个时间事件主要由以下三个属性组成:

  • id:服务器为时间事件创建的全局唯一 ID(标识号),从小到大顺序递增,新事件的 ID 比旧事件的 ID 号要大
  • when:毫秒精度的 UNIX 时间戳,记录了时间事件的到达(arrive)时间
  • timeProc:时间事件处理器,当时间事件到达时,服务器就会调用相应的处理器来处理事件

时间事件是定时事件还是周期性事件取决于时间事件处理器的返回值:

  • 定时事件:事件处理器返回 AE_NOMORE,该事件在到达一次后就会被删除
  • 周期事件:事件处理器返回非 AE_NOMORE 的整数值,服务器根据该值对事件的 when 属性更新,让该事件在一段时间后再次交付

服务器将所有时间事件都放在一个无序链表中,新的时间事件插入到链表的表头:

无序链表指是链表不按 when 属性的大小排序,每当时间事件执行器运行时就必须遍历整个链表,查找所有已到达的时间事件,并调用相应的事件处理器处理

无序链表并不影响时间事件处理器的性能,因为正常模式下的 Redis 服务器只使用 serverCron 一个时间事件,在 benchmark 模式下服务器也只使用两个时间事件,所以无序链表不会影响服务器的性能,几乎可以按照一个指针处理

服务器 → serverCron 详解该时间事件


事件调度

服务器中同时存在文件事件和时间事件两种事件类型,调度伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 事件调度伪代码
def aeProcessEvents():
# 获取到达时间离当前时间最接近的时间事件
time_event = aeSearchNearestTime()

# 计算最接近的时间事件距离到达还有多少亳秒
remaind_ms = time_event.when - unix_ts_now()
# 如果事件已到达,那么 remaind_ms 的值可能为负数,设置为 0
if remaind_ms < 0:
remaind_ms = 0

# 根据 remaind_ms 的值,创建 timeval 结构
timeval = create_timeval_with_ms(remaind_ms)
# 【阻塞并等待文件事件】产生,最大阻塞时间由传入的timeval结构决定,remaind_ms的值为0时调用后马上返回,不阻塞
aeApiPoll(timeval)

# 处理所有已产生的文件事件
processFileEvents()
# 处理所有已到达的时间事件
processTimeEvents()

事件的调度和执行规则:

  • aeApiPoll 函数的最大阻塞时间由到达时间最接近当前时间的时间事件决定,可以避免服务器对时间事件进行频繁的轮询(忙等待),也可以确保 aeApiPoll 函数不会阻塞过长时间
  • 对文件事件和时间事件的处理都是同步、有序、原子地执行,服务器不会中途中断事件处理,也不会对事件进行抢占,所以两种处理器都要尽可地减少程序的阻塞时间,并在有需要时主动让出执行权,从而降低事件饥饿的可能性
    • 命令回复处理器在写入字节数超过了某个预设常量,就会主动用 break 跳出写入循环,将余下的数据留到下次再写
    • 时间事件也会将非常耗时的持久化操作放到子线程或者子进程执行
  • 时间事件在文件事件之后执行,并且事件之间不会出现抢占,所以时间事件的实际处理时间通常会比设定的到达时间稍晚

多线程

Redis6.0 引入多线程主要是为了提高网络 IO 读写性能,因为这是 Redis 的一个性能瓶颈(Redis 的瓶颈主要受限于内存和网络),多线程只是用来处理网络数据的读写和协议解析, 执行命令仍然是单线程顺序执行,因此不需要担心线程安全问题。

Redis6.0 的多线程默认是禁用的,只使用主线程。如需开启需要修改 redis 配置文件 redis.conf

1
io-threads-do-reads yesCopy to clipboardErrorCopied

开启多线程后,还需要设置线程数,否则是不生效的,同样需要修改 redis 配置文件 :

1
io-threads 4 #官网建议4核的机器建议设置为2或3个线程,8核的建议设置为6个线程

参考文章:https://mp.weixin.qq.com/s/dqmiR0ECf4lB6Y2OyK-dyA


客户端

基本介绍

Redis 服务器是典型的一对多程序,一个服务器可以与多个客户端建立网络连接,服务器对每个连接的客户端建立了相应的 redisClient 结构(客户端状态,在服务器端的存储结构),保存了客户端当前的状态信息,以及执行相关功能时需要用到的数据结构

Redis 服务器状态结构的 clients 属性是一个链表,这个链表保存了所有与服务器连接的客户端的状态结构:

1
2
3
4
5
6
struct redisServer {
// 一个链表,保存了所有客户端状态
list *clients;

//...
};


数据结构

redisClient

客户端的数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
typedef struct redisClient {
//...

// 套接字
int fd;
// 名字
robj *name;
// 标志
int flags;

// 输入缓冲区
sds querybuf;
// 输出缓冲区 buf 数组
char buf[REDIS_REPLY_CHUNK_BYTES];
// 记录了 buf 数组目前已使用的字节数量
int bufpos;
// 可变大小的输出缓冲区,链表 + 字符串对象
list *reply;

// 命令数组
rboj **argv;
// 命令数组的长度
int argc;
// 命令的信息
struct redisCommand *cmd;

// 是否通过身份验证
int authenticated;

// 创建客户端的时间
time_t ctime;
// 客户端与服务器最后一次进行交互的时间
time_t lastinteraction;
// 输出缓冲区第一次到达软性限制 (soft limit) 的时间
time_t obuf_soft_limit_reached_time;
}

客户端状态包括两类属性

  • 一类是比较通用的属性,这些属性很少与特定功能相关,无论客户端执行的是什么工作,都要用到这些属性
  • 另一类是和特定功能相关的属性,比如操作数据库时用到的 db 属性和 dict id 属性,执行事务时用到的 mstate 属性,以及执行 WATCH 命令时用到的 watched_keys 属性等,代码中没有列出

套接字

客户端状态的 fd 属性记录了客户端正在使用的套接字描述符,根据客户端类型的不同,fd 属性的值可以是 -1 或者大于 -1 的整数:

  • 伪客户端 (fake client) 的 fd 属性的值为 -1,命令请求来源于 AOF 文件或者 Lua 脚本,而不是网络,所以不需要套接字连接
  • 普通客户端的 fd 属性的值为大于 -1 的整数,因为合法的套接字描述符不能是 -1

执行 CLIENT list 命令可以列出目前所有连接到服务器的普通客户端,不包括伪客户端


名字

在默认情况下,一个连接到服务器的客户端是没有名字的,使用 CLIENT setname 命令可以为客户端设置一个名字


标志

客户端的标志属性 flags 记录了客户端的角色以及客户端目前所处的状态,每个标志使用一个常量表示

  • flags 的值可以是单个标志:flags = <flag>
  • flags 的值可以是多个标志的二进制:flags = <flagl> | <flag2> | ...

一部分标志记录客户端的角色

  • REDIS_MASTER 表示客户端是一个从服务器,REDIS_SLAVE 表示客户端是一个从服务器,在主从复制时使用
  • REDIS_PRE_PSYNC 表示客户端是一个版本低于 Redis2.8 的从服务器,主服务器不能使用 PSYNC 命令与该从服务器进行同步,这个标志只能在 REDIS_ SLAVE 标志处于打开状态时使用
  • REDIS_LUA_CLIENT 表示客户端是专门用于处理 Lua 脚本里面包含的 Redis 命令的伪客户端

一部分标志记录目前客户端所处的状态

  • REDIS_UNIX_SOCKET 表示服务器使用 UNIX 套接字来连接客户端
  • REDIS_BLOCKED 表示客户端正在被 BRPOP、BLPOP 等命令阻塞
  • REDIS_UNBLOCKED 表示客户端已经从 REDIS_BLOCKED 所表示的阻塞状态脱离,在 REDIS_BLOCKED 标志打开的情况下使用
  • REDIS_MULTI 标志表示客户端正在执行事务
  • REDIS_DIRTY_CAS 表示事务使用 WATCH 命令监视的数据库键已经被修改
  • …..

缓冲区

客户端状态的输入缓冲区用于保存客户端发送的命令请求,输入缓冲区的大小会根据输入内容动态地缩小或者扩大,但最大大小不能超过 1GB,否则服务器将关闭这个客户端,比如执行 SET key value ,那么缓冲区 querybuf 的内容:

1
*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n # 

输出缓冲区是服务器用于保存执行客户端命令所得的命令回复,每个客户端都有两个输出缓冲区可用:

  • 一个是固定大小的缓冲区,保存长度比较小的回复,比如 OK、简短的字符串值、整数值、错误回复等
  • 一个是可变大小的缓冲区,保存那些长度比较大的回复, 比如一个非常长的字符串值或者一个包含了很多元素的集合等

buf 是一个大小为 REDIS_REPLY_CHUNK_BYTES (常量默认 16*1024 = 16KB) 字节的字节数组,bufpos 属性记录了 buf 数组目前已使用的字节数量,当 buf 数组的空间已经用完或者回复数据太大无法放进 buf 数组里,服务器就会开始使用可变大小的缓冲区

通过使用 reply 链表连接多个字符串对象,可以为客户端保存一个非常长的命令回复,而不必受到固定大小缓冲区 16KB 大小的限制


命令

服务器对 querybuf 中的命令请求的内容进行分析,得出的命令参数以及参数的数量分别保存到客户端状态的 argv 和 argc 属性

  • argv 属性是一个数组,数组中的每项都是字符串对象,其中 argv[0] 是要执行的命令,而之后的其他项则是命令的参数
  • argc 属性负责记录 argv 数组的长度

服务器将根据项 argv[0] 的值,在命令表中查找命令所对应的命令的 redisCommand,将客户端状态的 cmd 指向该结构

命令表是一个字典结构,键是 SDS 结构保存命令的名字;值是命令所对应的 redisCommand 结构,保存了命令的实现函数、命令标志、 命令应该给定的参数个数、命令的总执行次数和总消耗时长等统计信息


验证

客户端状态的 authenticated 属性用于记录客户端是否通过了身份验证

  • authenticated 值为 0,表示客户端未通过身份验证
  • authenticated 值为 1,表示客户端已通过身份验证

当客户端 authenticated = 0 时,除了 AUTH 命令之外, 客户端发送的所有其他命令都会被服务器拒绝执行

1
2
3
4
5
6
redis> PING 
(error) NOAUTH Authentication required.
redis> AUTH 123321
OK
redis> PING
PONG

时间

ctime 属性记录了创建客户端的时间,这个时间可以用来计算客户端与服务器已经连接了多少秒,CLIENT list 命令的 age 域记录了这个秒数

lastinteraction 属性记录了客户端与服务器最后一次进行互动 (interaction) 的时间,互动可以是客户端向服务器发送命令请求,也可以是服务器向客户端发送命令回复。该属性可以用来计算客户端的空转 (idle) 时长, 就是距离客户端与服务器最后一次进行互动已经过去了多少秒,CLIENT list 命令的 idle 域记录了这个秒数

obuf_soft_limit_reached_time 属性记录了输出缓冲区第一次到达软性限制 (soft limit) 的时间


生命周期

创建

服务器使用不同的方式来创建和关闭不同类型的客户端

如果客户端是通过网络连接与服务器进行连接的普通客户端,那么在客户端使用 connect 函数连接到服务器时,服务器就会调用连接应答处理器为客户端创建相应的客户端状态,并将这个新的客户端状态添加到服务器状态结构 clients 链表的末尾

服务器会在初始化时创建负责执行 Lua 脚本中包含的 Redis 命令的伪客户端,并将伪客户端关联在服务器状态的 lua_client 属性

1
2
3
4
5
6
struct redisServer {
// 保存伪客户端
redisClient *lua_client;

//...
};

lua_client 伪客户端在服务器运行的整个生命周期会一直存在,只有服务器被关闭时,这个客户端才会被关闭

载入 AOF 文件时, 服务器会创建用于执行 AOF 文件包含的 Redis 命令的伪客户端,并在载入完成之后,关闭这个伪客户端


关闭

一个普通客户端可以因为多种原因而被关闭:

  • 客户端进程退出或者被杀死,那么客户端与服务器之间的网络连接将被关闭,从而造成客户端被关闭
  • 客户端向服务器发送了带有不符合协议格式的命令请求,那么这个客户端会被服务器关闭
  • 客户端是 CLIENT KILL 命令的目标
  • 如果用户为服务器设置了 timeout 配置选项,那么当客户端的空转时间超过该值时将被关闭,特殊情况不会被关闭:
    • 客户端是主服务器(REDIS_MASTER )或者从服务器(打开了 REDIS_SLAVE 标志)
    • 正在被 BLPOP 等命令阻塞(REDIS_BLOCKED)
    • 正在执行 SUBSCRIBE、PSUBSCRIBE 等订阅命令
  • 客户端发送的命令请求的大小超过了输入缓冲区的限制大小(默认为 1GB)
  • 发送给客户端的命令回复的大小超过了输出缓冲区的限制大小

理论上来说,可变缓冲区可以保存任意长的命令回复,但是为了回复过大占用过多的服务器资源,服务器会时刻检查客户端的输出缓冲区的大小,并在缓冲区的大小超出范围时,执行相应的限制操作:

  • 硬性限制 (hard limit):输出缓冲区的大小超过了硬性限制所设置的大小,那么服务器会关闭客户端(serverCron 函数中执行),积存在输出缓冲区中的所有内容会被直接释放,不会返回给客户端
  • 软性限制 (soft limit):输出缓冲区的大小超过了软性限制所设置的大小,小于硬性限制的大小,服务器的操作:
    • 用属性 obuf_soft_limit_reached_time 记录下客户端到达软性限制的起始时间,继续监视客户端
    • 如果输出缓冲区的大小一直超出软性限制,并且持续时间超过服务器设定的时长,那么服务器将关闭客户端
    • 如果在指定时间内不再超出软性限制,那么客户端就不会被关闭,并且 o_s_l_r_t 属性清零

使用 client-output-buffer-limit 选项可以为普通客户端、从服务器客户端、执行发布与订阅功能的客户端分别设置不同的软性限制和硬性限制,格式:

1
2
3
4
5
client-output-buffer-limit <class> <hard limit> <soft limit> <soft seconds>

client-output-buffer-limit normal 0 0 0
client-output-buffer-limit slave 256mb 64mb 60
client-output-buffer-limit pubsub 32mb 8mb 60
  • 第一行:将普通客户端的硬性限制和软性限制都设置为 0,表示不限制客户端的输出缓冲区大小
  • 第二行:将从服务器客户端的硬性限制设置为 256MB,软性限制设置为 64MB,软性限制的时长为 60 秒
  • 第三行:将执行发布与订阅功能的客户端的硬性限制设置为 32MB,软性限制设置为 8MB,软性限制的时长为 60 秒

服务器

执行流程

Redis 服务器与多个客户端建立网络连接,处理客户端发送的命令请求,在数据库中保存客户端执行命令所产生的数据,并通过资源管理来维持服务器自身的运转,所以一个命令请求从发送到获得回复的过程中,客户端和服务器需要完成一系列操作

命令请求

Redis 服务器的命令请求来自 Redis 客户端,当用户在客户端中键入一个命令请求时,客户端会将这个命令请求转换成协议格式,通过连接到服务器的套接字,将协议格式的命令请求发送给服务器

1
2
SET KEY VALUE ->	# 命令
*3\r\nS3\r\nSET\r\n$3\r\nKEY\r\n$5\r\nVALUE\r\n # 协议格式

当客户端与服务器之间的连接套接字因为客户端的写入而变得可读,服务器调用命令请求处理器来执行以下操作:

  • 读取套接字中协议格式的命令请求,并将其保存到客户端状态的输入缓冲区里面
  • 对输入缓冲区中的命令请求进行分析,提取出命令请求中包含的命令参数以及命令参数的个数,然后分别将参数和参数个数保存到客户端状态的 argv 属性和 argc 属性里
  • 调用命令执行器,执行客户端指定的命令

最后客户端接收到协议格式的命令回复之后,会将这些回复转换成用户可读的格式打印给用户观看,至此整体流程结束


命令执行

命令执行器开始对命令操作:

  • 查找命令:首先根据客户端状态的 argv[0] 参数,在命令表 (command table) 中查找参数所指定的命令,并将找到的命令保存到客户端状态的 cmd 属性里面,是一个 redisCommand 结构

    命令查找算法与字母的大小写无关,所以命令名字的大小写不影响命令表的查找结果

  • 执行预备操作:

    • 检查客户端状态的 cmd 指针是否指向 NULL,根据 redisCommand 检查请求参数的数量是否正确
    • 检查客户端是否通过身份验证
    • 如果服务器打开了 maxmemory 功能,执行命令之前要先检查服务器的内存占用,在有需要时进行内存回收(逐出算法
    • 如果服务器上一次执行 BGSAVE 命令出错,并且服务器打开了 stop-writes-on-bgsave-error 功能,那么如果本次执行的是写命令,服务会拒绝执行,并返回错误
    • 如果客户端当前正在用 SUBSCRIBE 或 PSUBSCRIBE 命令订阅频道,那么服务器会拒绝除了 SUBSCRIBE、SUBSCRIBE、 UNSUBSCRIBE、PUNSUBSCRIBE 之外的其他命令
    • 如果服务器正在进行载入数据,只有 sflags 带有 1 标识(比如 INFO、SHUTDOWN、PUBLISH等)的命令才会被执行
    • 如果服务器执行 Lua 脚本而超时并进入阻塞状态,那么只会执行客户端发来的 SHUTDOWN nosave 和 SCRIPT KILL 命令
    • 如果客户端正在执行事务,那么服务器只会执行客户端发来的 EXEC、DISCARD、MULTI、WATCH 四个命令,其他命令都会被放进事务队列
    • 如果服务器打开了监视器功能,那么会将要执行的命令和参数等信息发送给监视器
  • 调用命令的实现函数:被调用的函数会执行指定的操作并产生相应的命令回复,回复会被保存在客户端状态的输出缓冲区里面(buf 和 reply 属性),然后实现函数还会为客户端的套接字关联命令回复处理器,这个处理器负责将命令回复返回给客户端

  • 执行后续工作:

    • 如果服务器开启了慢查询日志功能,那么慢查询日志模块会检查是否需要为刚刚执行完的命令请求添加一条新的慢查询日志
    • 根据执行命令所耗费的时长,更新命令的 redisCommand 结构的 milliseconds 属性,并将命令 calls 计数器的值增一
    • 如果服务器开启了 AOF 持久化功能,那么 AOF 持久化模块会将执行的命令请求写入到 AOF 缓冲区里面
    • 如果有其他从服务器正在复制当前这个服务器,那么服务器会将执行的命令传播给所有从服务器
  • 将命令回复发送给客户端:客户端套接字变为可写状态时,服务器就会执行命令回复处理器,将客户端输出缓冲区中的命令回复发送给客户端,发送完毕之后回复处理器会清空客户端状态的输出缓冲区,为处理下一个命令请求做好准备


Command

每个 redisCommand 结构记录了一个Redis 命令的实现信息,主要属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct redisCommand {
// 命令的名字,比如"set"
char *name;

// 函数指针,指向命令的实现函数,比如setCommand
// redisCommandProc 类型的定义为 typedef void redisCommandProc(redisClient *c)
redisCommandProc *proc;

// 命令参数的个数,用于检查命令请求的格式是否正确。如果这个值为负数-N, 那么表示参数的数量大于等于N。
// 注意命令的名字本身也是一个参数,比如 SET msg "hello",命令的参数是"SET"、"msg"、"hello" 三个
int arity;

// 字符串形式的标识值,这个值记录了命令的属性,,
// 比如这个命令是写命令还是读命令,这个命令是否允许在载入数据时使用,是否允许在Lua脚本中使用等等
char *sflags;

// 对sflags标识进行分析得出的二进制标识,由程序自动生成。服务器对命令标识进行检查时使用的都是 flags 属性
// 而不是sflags属性,因为对二进制标识的检查可以方便地通过& ^ ~ 等操作来完成
int flags;

// 服务器总共执行了多少次这个命令
long long calls;

// 服务器执行这个命令所耗费的总时长
long long milliseconds;
};

serverCron

基本介绍

Redis 服务器以周期性事件的方式来运行 serverCron 函数,服务器初始化时读取配置 server.hz 的值,默认为 10,代表每秒钟执行 10 次,即每隔 100 毫秒执行一次,执行指令 info server 可以查看

serverCron 函数负责定期对自身的资源和状态进行检查和调整,从而确保服务器可以长期、稳定地运行

  • 更新服务器的各类统计信息,比如时间、内存占用、 数据库占用情况等
  • 清理数据库中的过期键值对
  • 关闭和清理连接失效的客户端
  • 进行 AOF 或 RDB 持久化操作
  • 如果服务器是主服务器,那么对从服务器进行定期同步
  • 如果处于集群模式,对集群进行定期同步和连接测试

时间缓存

Redis 服务器中有很多功能需要获取系统的当前时间,而每次获取系统的当前时间都需要执行一次系统调用,为了减少系统调用的执行次数,服务器状态中的 unixtime 属性和 mstime 属性被用作当前时间的缓存

1
2
3
4
5
6
7
struct redisServer {
// 保存了秒级精度的系统当前UNIX时间戳
time_t unixtime;
// 保存了毫秒级精度的系统当前UNIX时间戳
long long mstime;

};

serverCron 函数默认以每 100 毫秒一次的频率更新两个属性,所以属性记录的时间的精确度并不高

  • 服务器只会在打印日志、更新服务器的 LRU 时钟、决定是否执行持久化任务、计算服务器上线时间(uptime)这类对时间精确度要求不高的功能上
  • 对于为键设置过期时间、添加慢查询日志这种需要高精确度时间的功能来说,服务器还是会再次执行系统调用,从而获得最准确的系统当前时间

LRU 时钟

服务器状态中的 lruclock 属性保存了服务器的 LRU 时钟

1
2
3
4
struct redisServer {
// 默认每10秒更新一次的时钟缓存,用于计算键的空转(idle)时长。
unsigned lruclock:22;
};

每个 Redis 对象都会有一个 lru 属性, 这个 lru 属性保存了对象最后一次被命令访问的时间

1
2
3
typedef struct redisObiect {
unsigned lru:22;
} robj;

当服务器要计算一个数据库键的空转时间(即数据库键对应的值对象的空转时间),程序会用服务器的 lruclock 属性记录的时间减去对象的 lru 属性记录的时间

serverCron 函数默认以每 100 毫秒一次的频率更新这个属性,所以得出的空转时间也是模糊的


命令次数

serverCron 中的 trackOperationsPerSecond 函数以每 100 毫秒一次的频率执行,函数功能是以抽样计算的方式,估算并记录服务器在最近一秒钟处理的命令请求数量,这个值可以通过 INFO status 命令的 instantaneous_ops_per_sec 域查看:

1
2
3
redis> INFO stats
# Stats
instantaneous_ops_per_sec:6

根据上一次抽样时间 ops_sec_last_sample_time 和当前系统时间,以及上一次已执行的命令数 ops_sec_last_sample_ops 和服务器当前已经执行的命令数,计算出两次函数调用期间,服务器平均每毫秒处理了多少个命令请求,该值乘以 1000 得到每秒内的执行命令的估计值,放入 ops_sec_samples 环形数组里

1
2
3
4
5
6
7
8
9
10
struct redisServer {
// 上一次进行抽样的时间
long long ops_sec_last_sample_time;
// 上一次抽样时,服务器已执行命令的数量
long long ops_sec_last_sample_ops;
// REDIS_OPS_SEC_SAMPLES 大小(默认值为16)的环形数组,数组的每一项记录一次的抽样结果
long long ops_sec_samples[REDIS_OPS_SEC_SAMPLES];
// ops_sec_samples数组的索引值,每次抽样后将值自增一,值为16时重置为0,让数组成为一个环形数组
int ops_sec_idx;
};

内存峰值

服务器状态里的 stat_peak_memory 属性记录了服务器内存峰值大小,循环函数每次执行时都会查看服务器当前使用的内存数量,并与 stat_peak_memory 保存的数值进行比较,设置为较大的值

1
2
3
4
struct redisServer {
// 已使用内存峰值
size_t stat_peak_memory;
};

INFO memory 命令的 used_memory_peak 和 used_memory_peak_human 两个域分别以两种格式记录了服务器的内存峰值:

1
2
3
4
5
redis> INFO memory 
# Memory
...
used_memory_peak:501824
used_memory_peak_human:490.06K

SIGTERM

服务器启动时,Redis 会为服务器进程的 SIGTERM 信号关联处理器 sigtermHandler 函数,该信号处理器负责在服务器接到 SIGTERM 信号时,打开服务器状态的 shutdown_asap 标识

1
2
3
4
struct redisServer {
// 关闭服务器的标识:值为1时关闭服务器,值为0时不做操作
int shutdown_asap;
};

每次 serverCron 函数运行时,程序都会对服务器状态的 shutdown_asap 属性进行检查,并根据属性的值决定是否关闭服务器

服务器在接到 SIGTERM 信号之后,关闭服务器并打印相关日志的过程:

1
2
3
4
5
[6794 | signal handler] (1384435690) Received SIGTERM, scheduling shutdown ... 
[6794] 14 Nov 21:28:10.108 # User requested shutdown ...
[6794] 14 Nov 21:28:10.108 * Saving the final RDB snapshot before exiting.
[6794) 14 Nov 21:28:10.161 * DB saved on disk
[6794) 14 Nov 21:28:10.161 # Redisis now ready to exit, bye bye ...

管理资源

serverCron 函数每次执行都会调用 clientsCron 和 databasesCron 函数,进行管理客户端资源和数据库资源

clientsCron 函数对一定数量的客户端进行以下两个检查:

  • 如果客户端与服务器之间的连接巳经超时(很长一段时间客户端和服务器都没有互动),那么程序释放这个客户端
  • 如果客户端在上一次执行命令请求之后,输入缓冲区的大小超过了一定的长度,那么程序会释放客户端当前的输入缓冲区,并重新创建一个默认大小的输入缓冲区,从而防止客户端的输入缓冲区耗费了过多的内存

databasesCron 函数会对服务器中的一部分数据库进行检查,删除其中的过期键,并在有需要时对字典进行收缩操作


持久状态

服务器状态中记录执行 BGSAVE 命令和 BGREWRITEAOF 命令的子进程的 ID

1
2
3
4
5
6
struct redisServer {
// 记录执行BGSAVE命令的子进程的ID,如果服务器没有在执行BGSAVE,那么这个属性的值为-1
pid_t rdb_child_pid;
// 记录执行BGREWRITEAOF命令的子进程的ID,如果服务器没有在执行那么这个属性的值为-1
pid_t aof_child_pid
};

serverCron 函数执行时,会检查两个属性的值,只要其中一个属性的值不为 -1,程序就会执行一次 wait3 函数,检查子进程是否有信号发来服务器进程:

  • 如果有信号到达,那么表示新的 RDB 文件已经生成或者 AOF 重写完毕,服务器需要进行相应命令的后续操作,比如用新的 RDB 文件替换现有的 RDB 文件,用重写后的 AOF 文件替换现有的 AOF 文件
  • 如果没有信号到达,那么表示持久化操作未完成,程序不做动作

如果两个属性的值都为 -1,表示服务器没有进行持久化操作

  • 查看是否有 BGREWRITEAOF 被延迟,然后执行 AOF 后台重写

  • 查看服务器的自动保存条件是否已经被满足,并且服务器没有在进行持久化,就开始一次新的 BGSAVE 操作

    因为条件 1 可能会引发一次 AOF,所以在这个检查中会再次确认服务器是否已经在执行持久化操作

  • 检查服务器设置的 AOF 重写条件是否满足,条件满足并且服务器没有进行持久化,就进行一次 AOF 重写

如果服务器开启了 AOF 持久化功能,并且 AOF 缓冲区里还有待写入的数据, 那么 serverCron 函数会调用相应的程序,将 AOF 缓冲区中的内容写入到 AOF 文件里


延迟执行

在服务器执行 BGSAVE 命令的期间,如果客户端发送 BGREWRITEAOF 命令,那么服务器会将 BGREWRITEAOF 命令的执行时间延迟到 BGSAVE 命令执行完毕之后,用服务器状态的 aof_rewrite_scheduled 属性标识延迟与否

1
2
3
4
struct redisServer {
// 如果值为1,那么表示有 BGREWRITEAOF命令被延迟了
int aof_rewrite_scheduled;
};

serverCron 函数会检查 BGSAVE 或者 BGREWRITEAOF 命令是否正在执行,如果这两个命令都没在执行,并且 aof_rewrite_scheduled 属性的值为 1,那么服务器就会执行之前被推延的 BGREWRITEAOF 命令


执行次数

服务器状态的 cronloops 属性记录了 serverCron 函数执行的次数

1
2
3
4
struct redisServer {
// serverCron 函数每执行一次,这个属性的值就增 1
int cronloops;
};

缓冲限制

服务器会关闭那些输入或者输出缓冲区大小超出限制的客户端


初始化

初始结构

一个 Redis 服务器从启动到能够接受客户端的命令请求,需要经过一系列的初始化和设置过程

第一步:创建一个 redisServer 类型的实例变量 server 作为服务器的状态,并为结构中的各个属性设置默认值,由 initServerConfig 函数进行初始化一般属性:

  • 设置服务器的运行 ID、默认运行频率、默认配置文件路径、默认端口号、默认 RDB 持久化条件和 AOF 持久化条件
  • 初始化服务器的 LRU 时钟,创建命令表

第二步:载入配置选项,用户可以通过给定配置参数或者指定配置文件,对 server 变量相关属性的默认值进行修改

第三步:初始化服务器数据结构(除了命令表之外),因为服务器必须先载入用户指定的配置选项才能正确地对数据结构进行初始化,所以载入配置完成后才进性数据结构的初始化,服务器将调用 initServer 函数:

  • server.clients 链表,记录了的客户端的状态结构;server.db 数组,包含了服务器的所有数据库
  • 用于保存频道订阅信息的 server.pubsub_channels 字典, 以及保存模式订阅信息的 server.pubsub_patterns 链表
  • 用于执行 Lua 脚本的 Lua 环境 server.lua
  • 保存慢查询日志的 server.slowlog 属性

initServer 还进行了非常重要的设置操作:

  • 为服务器设置进程信号处理器
  • 创建共享对象,包含 OK、ERR、整数 1 到 10000 的字符串对象
  • 打开服务器的监听端口
  • 为 serverCron 函数创建时间事件, 等待服务器正式运行时执行 serverCron 函数
  • 如果 AOF 持久化功能已经打开,那么打开现有的 AOF 文件,如果 AOF 文件不存在,那么创建并打开一个新的 AOF 文件 ,为 AOF 写入做好准备
  • 初始化服务器的后台 I/O 模块(BIO), 为将来的 I/O 操作做好准备

当 initServer 函数执行完毕之后, 服务器将用 ASCII 字符在日志中打印出 Redis 的图标, 以及 Redis 的版本号信息


还原状态

在完成了对服务器状态的初始化之后,服务器需要载入RDB文件或者AOF 文件, 并根据文件记录的内容来还原服务器的数据库状态:

  • 如果服务器启用了 AOF 持久化功能,那么服务器使用 AOF 文件来还原数据库状态
  • 如果服务器没有启用 AOF 持久化功能,那么服务器使用 RDB 文件来还原数据库状态

当服务器完成数据库状态还原工作之后,服务器将在日志中打印出载入文件并还原数据库状态所耗费的时长

1
[7171] 22 Nov 22:43:49.084 * DB loaded from disk: 0.071 seconds 

驱动循环

在初始化的最后一步,服务器将打印出以下日志,并开始执行服务器的事件循环(loop)

1
[7171] 22 Nov 22:43:49.084 * The server is now ready to accept connections on pert 6379

服务器现在开始可以接受客户端的连接请求,并处理客户端发来的命令请求了


慢日志

基本介绍

Redis 的慢查询日志功能用于记录执行时间超过给定时长的命令请求,通过产生的日志来监视和优化查询速度

服务器配置有两个和慢查询日志相关的选项:

  • slowlog-log-slower-than 选项指定执行时间超过多少微秒的命令请求会被记录到日志上
  • slowlog-max-len 选项指定服务器最多保存多少条慢查询日志

服务器使用先进先出 FIFO 的方式保存多条慢查询日志,当服务器存储的慢查询日志数量等于 slowlog-max-len 选项的值时,在添加一条新的慢查询日志之前,会先将最旧的一条慢查询日志删除

配置选项可以通过 CONFIG SET option value 命令进行设置

常用命令:

1
2
3
SLOWLOG GET [n]	# 查看 n 条服务器保存的慢日志
SLOWLOG LEN # 查看日志数量
SLOWLOG RESET # 清除所有慢查询日志

日志保存

服务器状态中包含了慢查询日志功能有关的属性:

1
2
3
4
5
6
7
8
9
10
11
12
struct redisServer {
// 下一条慢查询日志的ID
long long slowlog_entry_id;

// 保存了所有慢查询日志的链表
list *slowlog;

// 服务器配置选项的值
long long slowlog-log-slower-than;
// 服务器配置选项的值
unsigned long slowlog_max_len;
}

slowlog_entry_id 属性的初始值为 0,每当创建一条新的慢查询日志时,这个属性就会用作新日志的 id 值,之后该属性增一

slowlog 链表保存了服务器中的所有慢查询日志,链表中的每个节点是一个 slowlogEntry 结构, 代表一条慢查询日志:

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct slowlogEntry {
// 唯一标识符
long long id;
// 命令执行时的时间,格式为UNIX时间戳
time_t time;
// 执行命令消耗的时间,以微秒为单位
long long duration;
// 命令与命令参数
robj **argv;
// 命令与命令参数的数量
int argc;
}

添加日志

在每次执行命令的前后,程序都会记录微秒格式的当前 UNIX 时间戳,两个时间之差就是执行命令所耗费的时长,函数会检查命令的执行时长是否超过 slowlog-log-slower-than 选项所设置:

  • 如果是的话,就为命令创建一个新的日志,并将新日志添加到 slowlog 链表的表头

  • 检查慢查询日志的长度是否超过 slowlog-max-len 选项所设置的长度,如果是将多出来的日志从 slowlog 链表中删除掉

  • 将 redisServer. slowlog_entry_id 的值增 1


数据结构

字符串

SDS

Redis 构建了简单动态字符串(SDS)的数据类型,作为 Redis 的默认字符串表示,包含字符串的键值对在底层都是由 SDS 实现

1
2
3
4
5
6
7
8
9
10
struct sdshdr {
// 记录buf数组中已使用字节的数量,等于 SDS 所保存字符串的长度
int len;

// 记录buf数组中未使用字节的数量
int free;

// 【字节】数组,用于保存字符串(不是字符数组)
char buf[];
};

SDS 遵循 C 字符串以空字符结尾的惯例,保存空字符的 1 字节不计算在 len 属性,SDS 会自动为空字符分配额外的 1 字节空间和添加空字符到字符串末尾,所以空字符对于 SDS 的使用者来说是完全透明的


对比

常数复杂度获取字符串长度:

  • C 字符串不记录自身的长度,获取时需要遍历整个字符串,遇到空字符串为止,时间复杂度为 O(N)
  • SDS 获取字符串长度的时间复杂度为 O(1),设置和更新 SDS 长度由函数底层自动完成

杜绝缓冲区溢出:

  • C 字符串调用 strcat 函数拼接字符串时,如果字符串内存不够容纳目标字符串,就会造成缓冲区溢出(Buffer Overflow)

    s1 和 s2 是内存中相邻的字符串,执行 strcat(s1, " Cluster")(有空格):

  • SDS 空间分配策略:当对 SDS 进行修改时,首先检查 SDS 的空间是否满足修改所需的要求, 如果不满足会自动将 SDS 的空间扩展至执行修改所需的大小,然后执行实际的修改操作, 避免了缓冲区溢出的问题

二进制安全:

  • C 字符串中的字符必须符合某种编码(比如 ASCII)方式,除了字符串末尾以外其他位置不能包含空字符,否则会被误认为是字符串的结尾,所以只能保存文本数据
  • SDS 的 API 都是二进制安全的,使用字节数组 buf 保存一系列的二进制数据,使用 len 属性来判断数据的结尾,所以可以保存图片、视频、压缩文件等二进制数据

兼容 C 字符串的函数:SDS 会在为 buf 数组分配空间时多分配一个字节来保存空字符,所以可以重用一部分 C 字符串函数库的函数


内存

C 字符串每次增长或者缩短都会进行一次内存重分配,拼接操作通过重分配扩展底层数组空间,截断操作通过重分配释放不使用的内存空间,防止出现内存泄露

SDS 通过未使用空间解除了字符串长度和底层数组长度之间的关联,在 SDS 中 buf 数组的长度不一定就是字符数量加一, 数组里面可以包含未使用的字节,字节的数量由 free 属性记录

内存重分配涉及复杂的算法,需要执行系统调用,是一个比较耗时的操作,SDS 的两种优化策略:

  • 空间预分配:当 SDS 的 API 进行修改并且需要进行空间扩展时,程序不仅会为 SDS 分配修改所必需的空间, 还会为 SDS 分配额外的未使用空间

    • 对 SDS 修改之后,SDS 的长度(len 属性)小于 1MB,程序分配和 len 属性同样大小的未使用空间,此时 len 和 free 相等

      s 为 Redis,执行 sdscat(s, " Cluster") 后,len 变为 13 字节,所以也分配了 13 字节的 free 空间,总长度变为 27 字节(额外的一字节保存空字符,13 + 13 + 1 = 27)

    • 对 SDS 修改之后,SDS 的长度大于等于 1MB,程序会分配 1MB 的未使用空间

    在扩展 SDS 空间前,API 会先检查 free 空间是否足够,如果足够就无需执行内存重分配,所以通过预分配策略,SDS 将连续增长 N 次字符串所需内存的重分配次数从必定 N 次降低为最多 N 次

  • 惰性空间释放:当 SDS 的 API 需要缩短字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用 free 属性将这些字节的数量记录起来,并等待将来使用

    SDS 提供了相应的 API 来真正释放 SDS 的未使用空间,所以不用担心空间惰性释放策略造成的内存浪费问题


链表

链表提供了高效的节点重排能力,C 语言并没有内置这种数据结构,所以 Redis 构建了链表数据类型

链表节点:

1
2
3
4
5
6
7
8
9
10
typedef struct listNode {
// 前置节点
struct listNode *prev;

// 后置节点
struct listNode *next;

// 节点的值
void *value
} listNode;

多个 listNode 通过 prev 和 next 指针组成双端链表

list 链表结构:提供了表头指针 head 、表尾指针 tail 以及链表长度计数器 len

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typedef struct list {
// 表头节点
listNode *head;
// 表尾节点
listNode *tail;

// 链表所包含的节点数量
unsigned long len;

// 节点值复制函数,用于复制链表节点所保存的值
void *(*dup) (void *ptr);
// 节点值释放函数,用于释放链表节点所保存的值
void (*free) (void *ptr);
// 节点值对比函数,用于对比链表节点所保存的值和另一个输入值是否相等
int (*match) (void *ptr, void *key);
} list;

Redis 链表的特性:

  • 双端:链表节点带有 prev 和 next 指针,获取某个节点的前置节点和后置节点的时间复杂度都是 O(1)
  • 无环:表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问以 NULL 为终点
  • 带表头指针和表尾指针: 通过 list 结构的 head 指针和 tail 指针,获取链表的表头节点和表尾节点的时间复杂度为 O(1)
  • 带链表长度计数器:使用 len 属性来对 list 持有的链表节点进行计数,获取链表中节点数量的时间复杂度为 O(1)
  • 多态:链表节点使用 void * 指针来保存节点值, 并且可以通过 dup、free 、match 三个属性为节点值设置类型特定函数,所以链表可以保存各种不同类型的值

字典

哈希表

Redis 字典使用的哈希表结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct dictht {
// 哈希表数组,数组中每个元素指向 dictEntry 结构
dictEntry **table;

// 哈希表大小,数组的长度
unsigned long size;

// 哈希表大小掩码,用于计算索引值,总是等于 【size-1】
unsigned long sizemask;

// 该哈希表已有节点的数量
unsigned long used;
} dictht;

哈希表节点结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct dictEntry {
// 键
void *key;

// 值,可以是一个指针,或者整数
union {
void *val; // 指针
uint64_t u64;
int64_t s64;
}

// 指向下个哈希表节点,形成链表,用来解决冲突问题
struct dictEntry *next;
} dictEntry;


字典结构

字典,又称为符号表、关联数组、映射(Map),用于保存键值对的数据结构,字典中的每个键都是独一无二的。底层采用哈希表实现,一个哈希表包含多个哈希表节点,每个节点保存一个键值对

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct dict {
// 类型特定函数
dictType *type;

// 私有数据
void *privdata;

// 哈希表,数组中的每个项都是一个dictht哈希表,
// 一般情况下字典只使用 ht[0] 哈希表, ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash 时使用
dictht ht[2];

// rehash 索引,当 rehash 不在进行时,值为 -1
int rehashidx;
} dict;

type 属性和 privdata 属性是针对不同类型的键值对, 为创建多态字典而设置的:

  • type 属性是指向 dictType 结构的指针, 每个 dictType 结构保存了一簇用于操作特定类型键值对的函数, Redis 会为用途不同的字典设置不同的类型特定函数
  • privdata 属性保存了需要传给那些类型特定函数的可选参数


哈希冲突

Redis 使用 MurmurHash 算法来计算键的哈希值,这种算法的优点在于,即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,并且算法的计算速度也非常快

将一个新的键值对添加到字典里,需要先根据键 key 计算出哈希值,然后进行取模运算(取余):

1
index = hash & dict->ht[x].sizemask

当有两个或以上数量的键被分配到了哈希表数组的同一个索引上时,就称这些键发生了哈希冲突(collision)

Redis 的哈希表使用链地址法(separate chaining)来解决键哈希冲突, 每个哈希表节点都有一个 next 指针,多个节点通过 next 指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这就解决了键冲突的问题

dictEntry 节点组成的链表没有指向链表表尾的指针,为了速度考虑,程序总是将新节点添加到链表的表头位置(头插法),时间复杂度为 O(1)


负载因子

负载因子的计算方式:哈希表中的节点数量 / 哈希表的大小(长度

1
load_factor = ht[0].used / ht[0].size

为了让哈希表的负载因子(load factor)维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时 ,程序会自动对哈希表的大小进行相应的扩展或者收缩

哈希表执行扩容的条件:

  • 服务器没有执行 BGSAVE 或者 BGREWRITEAOF 命令,哈希表的负载因子大于等于 1

  • 服务器正在执行 BGSAVE 或者 BGREWRITEAOF 命令,哈希表的负载因子大于等于 5

    原因:执行该命令的过程中,Redis 需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on­-write)技术来优化子进程的使用效率,通过提高执行扩展操作的负载因子,尽可能地避免在子进程存在期间进行哈希表扩展操作,可以避免不必要的内存写入操作,最大限度地节约内存

哈希表执行收缩的条件:负载因子小于 0.1(自动执行,servreCron 中检测),缩小为字典中数据个数的 50% 左右


重新散列

扩展和收缩哈希表的操作通过 rehash(重新散列)来完成,步骤如下:

  • 为字典的 ht[1] 哈希表分配空间,空间大小的分配情况:
    • 如果执行的是扩展操作,ht[1] 的大小为第一个大于等于 $ht[0].used * 2$ 的 $2^n$
    • 如果执行的是收缩操作,ht[1] 的大小为第一个大于等于 $ht[0].used$ 的 $2^n$
  • 将保存在 ht[0] 中所有的键值对重新计算哈希值和索引值,迁移到 ht[1] 上
  • 当 ht[0] 包含的所有键值对都迁移到了 ht[1] 之后(ht[0] 变为空表),释放 ht[0],将 ht[1] 设置为 ht[0],并在 ht[1] 创建一个新的空白哈希表,为下一次 rehash 做准备

如果哈希表里保存的键值对数量很少,rehash 就可以在瞬间完成,但是如果哈希表里数据很多,那么要一次性将这些键值对全部 rehash 到 ht[1] 需要大量计算,可能会导致服务器在一段时间内停止服务

Redis 对 rehash 做了优化,使 rehash 的动作并不是一次性、集中式的完成,而是分多次,渐进式的完成,又叫渐进式 rehash

  • 为 ht[1] 分配空间,此时字典同时持有 ht[0] 和 ht[1] 两个哈希表
  • 在字典中维护了一个索引计数器变量 rehashidx,并将变量的值设为 0,表示 rehash 正式开始
  • 在 rehash 进行期间,每次对字典执行增删改查操作时,程序除了执行指定的操作以外,还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1],rehash 完成之后将 rehashidx 属性的值增一
  • 随着字典操作的不断执行,最终在某个时间点上 ht[0] 的所有键值对都被 rehash 至 ht[1],这时程序将 rehashidx 属性的值设为 -1,表示 rehash 操作已完成

渐进式 rehash 采用分而治之的方式,将 rehash 键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式 rehash 带来的庞大计算量

渐进式 rehash 期间的哈希表操作:

  • 字典的查找、删除、更新操作会在两个哈希表上进行,比如查找一个键会先在 ht[0] 上查找,查找不到就去 ht[1] 继续查找
  • 字典的添加操作会直接在 ht[1] 上添加,不在 ht[0] 上进行任何添加

跳跃表

底层结构

跳跃表(skiplist)是一种有序(默认升序)的数据结构,在链表的基础上增加了多级索引以提升查找的效率,索引是占内存的,所以是一个空间换时间的方案,跳表平均 O(logN)、最坏 O(N) 复杂度的节点查找,效率与平衡树相当但是实现更简单

原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值和几个指针,并不需要存储对象,因此当节点本身比较大或者元素数量比较多的时候,其优势可以被放大,而缺点(占内存)则可以忽略

Redis 只在两个地方应用了跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构

1
2
3
4
5
6
7
8
9
10
typedef struct zskiplist {
// 表头节点和表尾节点,O(1) 的时间复杂度定位头尾节点
struct skiplistNode *head, *tail;

// 表的长度,也就是表内的节点数量 (表头节点不计算在内)
unsigned long length;

// 表中层数最大的节点的层数 (表头节点的层高不计算在内)
int level
} zskiplist;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef struct zskiplistNode {
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];

// 后退指针
struct zskiplistNode *backward;

// 分值
double score;

// 成员对象
robj *obj;
} zskiplistNode;


属性分析

层:level 数组包含多个元素,每个元素包含指向其他节点的指针。根据幕次定律(power law,越大的数出现的概率越小)随机生成一个介于 1 和 32 之间的值(Redis5 之后最大为 64)作为 level 数组的大小,这个大小就是层的高度,节点的第一层是 level[0] = L1

前进指针:forward 用于从表头到表尾方向正序(升序)遍历节点,遇到 NULL 停止遍历

跨度:span 用于记录两个节点之间的距离,用来计算排位(rank)

  • 两个节点之间的跨度越大相距的就越远,指向 NULL 的所有前进指针的跨度都为 0

  • 在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,结果就是目标节点在跳跃表中的排位,按照上图所示:

    查找分值为 3.0 的节点,沿途经历的层:查找的过程只经过了一个层,并且层的跨度为 3,所以目标节点在跳跃表中的排位为 3

    查找分值为 2.0 的节点,沿途经历的层:经过了两个跨度为 1 的节点,因此可以计算出目标节点在跳跃表中的排位为 2

后退指针:backward 用于从表尾到表头方向逆序(降序)遍历节点

分值:score 属性一个 double 类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序

成员对象:obj 属性是一个指针,指向一个 SDS 字符串对象。同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值可以是相同的,分值相同的节点将按照成员对象在字典序中的大小来进行排序(从小到大)

个人笔记:JUC → 并发包 → ConcurrentSkipListMap 详解跳跃表


整数集合

底层结构

整数集合(intset)是用于保存整数值的集合数据结构,是 Redis 集合键的底层实现之一

1
2
3
4
5
6
7
8
9
10
typedef struct intset {
// 编码方式
uint32_t encoding;

// 集合包含的元素数量,也就是 contents 数组的长度
uint32_t length;

// 保存元素的数组
int8_t contents[];
} intset;

encoding 取值为三种:INTSET_ENC_INT16、INTSET_ENC_INT32、INTSET_ENC_INT64

整数集合的每个元素都是 contents 数组的一个数组项(item),在数组中按值的大小从小到大有序排列,并且数组中不包含任何重复项。虽然 contents 属性声明为 int8_t 类型,但实际上数组并不保存任何 int8_t 类型的值, 真正类型取决于 encoding 属性

说明:底层存储结构是数组,所以为了保证有序性和不重复性,每次添加一个元素的时间复杂度是 O(N)


升级降级

整数集合添加的新元素的类型比集合现有所有元素的类型都要长时,需要先进行升级(upgrade),升级流程:

  • 根据新元素的类型长度以及集合元素的数量(包括新元素在内),扩展整数集合底层数组的空间大小

  • 将底层数组现有的所有元素都转换成与新元素相同的类型,并将转换后的元素放入正确的位置,放置过程保证数组的有序性

    图示 32 * 4 = 128 位,首先将 3 放入索引 2(64 位 - 95 位),然后将 2 放置索引 1,将 1 放置在索引 0,从后向前依次放置在对应的区间,最后放置 65535 元素到索引 3(96 位- 127 位),修改 length 属性为 4

  • 将新元素添加到底层数组里

每次向整数集合添加新元素都可能会引起升级,而每次升级都需要对底层数组中的所有元素进行类型转换,所以向整数集合添加新元素的时间复杂度为 O(N)

引发升级的新元素的长度总是比整数集合现有所有元素的长度都大,所以这个新元素的值要么就大于所有现有元素,要么就小于所有现有元素,升级之后新元素的摆放位置:

  • 在新元素小于所有现有元素的情况下,新元素会被放置在底层数组的最开头(索引 0)
  • 在新元素大于所有现有元素的情况下,新元素会被放置在底层数组的最末尾(索引 length-1)

整数集合升级策略的优点:

  • 提升整数集合的灵活性:C 语言是静态类型语言,为了避免类型错误通常不会将两种不同类型的值放在同一个数据结构里面,整数集合可以自动升级底层数组来适应新元素,所以可以随意的添加整数

  • 节约内存:要让数组可以同时保存 int16、int32、int64 三种类型的值,可以直接使用 int64_t 类型的数组作为整数集合的底层实现,但是会造成内存浪费,整数集合可以确保升级操作只会在有需要的时候进行,尽量节省内存

整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态


压缩列表

底层结构

压缩列表(ziplist)是 Redis 为了节约内存而开发的,是列表键和哈希键的底层实现之一。是由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构,一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值

  • zlbytes:uint32_t 类型 4 字节,记录整个压缩列表占用的内存字节数,在对压缩列表进行内存重分配或者计算 zlend 的位置时使用
  • zltail:uint32_t 类型 4 字节,记录压缩列表表尾节点距离起始地址有多少字节,通过这个偏移量程序无须遍历整个压缩列表就可以确定表尾节点的地址
  • zllen:uint16_t 类型 2 字节,记录了压缩列表包含的节点数量,当该属性的值小于 UINT16_MAX (65535) 时,该值就是压缩列表中节点的数量;当这个值等于 UINT16_MAX 时节点的真实数量需要遍历整个压缩列表才能计算得出
  • entryX:列表节点,压缩列表中的各个节点,节点的长度由节点保存的内容决定
  • zlend:uint8_t 类型 1 字节,是一个特殊值 0xFF (255),用于标记压缩列表的末端

列表 zlbytes 属性的值为 0x50 (十进制 80),表示压缩列表的总长为 80 字节,列表 zltail 属性的值为 0x3c (十进制 60),假设表的起始地址为 p,计算得出表尾节点 entry3 的地址 p + 60


列表节点

列表节点 entry 的数据结构:

previous_entry_length:以字节为单位记录了压缩列表中前一个节点的长度,程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址,完成从表尾向表头遍历操作

  • 如果前一节点的长度小于 254 字节,该属性的长度为 1 字节,前一节点的长度就保存在这一个字节里
  • 如果前一节点的长度大于等于 254 字节,该属性的长度为 5 字节,其中第一字节会被设置为 0xFE(十进制 254),之后的四个字节则用于保存前一节点的长度

encoding:记录了节点的 content 属性所保存的数据类型和长度

  • 长度为 1 字节、2 字节或者 5 字节,值的最高位为 00、01 或者 10 的是字节数组编码,数组的长度由编码除去最高两位之后的其他位记录,下划线 _ 表示留空,而 bx 等变量则代表实际的二进制数据

  • 长度为 1 字节,值的最高位为 11 的是整数编码,整数值的类型和长度由编码除去最高两位之后的其他位记录

content:每个压缩列表节点可以保存一个字节数组或者一个整数值

  • 字节数组可以是以下三种长度的其中一种:

    • 长度小于等于 $63 (2^6-1)$ 字节的字节数组

    • 长度小于等于 $16383(2^{14}-1)$ 字节的字节数组

    • 长度小于等于 $4294967295(2^{32}-1)$ 字节的字节数组

  • 整数值则可以是以下六种长度的其中一种:

    • 4 位长,介于 0 至 12 之间的无符号整数

    • 1 字节长的有符号整数

    • 3 字节长的有符号整数

    • int16_t 类型整数

    • int32_t 类型整数

    • int64_t 类型整数


连锁更新

Redis 将在特殊情况下产生的连续多次空间扩展操作称之为连锁更新(cascade update)

假设在一个压缩列表中,有多个连续的、长度介于 250 到 253 字节之间的节点 e1 至 eN。将一个长度大于等于 254 字节的新节点 new 设置为压缩列表的头节点,new 就成为 e1 的前置节点。e1 的 previous_entry_length 属性仅为 1 字节,无法保存新节点 new 的长度,所以要对压缩列表执行空间重分配操作,并将 e1 节点的 previous_entry_length 属性从 1 字节长扩展为 5 字节长。由于 e1 原本的长度介于 250 至 253 字节之间,所以扩展后 e1 的长度就变成了 254 至 257 字节之间,导致 e2 的 previous_entry_length 属性无法保存 e1 的长度,程序需要不断地对压缩列表执行空间重分配操作,直到 eN 为止

删除节点也可能会引发连锁更新,big.length >= 254,small.length < 254,删除 small 节点

连锁更新在最坏情况下需要对压缩列表执行 N 次空间重分配,每次重分配的最坏复杂度为 O(N),所以连锁更新的最坏复杂度为 O(N^2)

说明:尽管连锁更新的复杂度较高,但出现的记录是非常低的,即使出现只要被更新的节点数量不多,就不会对性能造成影响


数据类型

redisObj

对象系统

Redis 使用对象来表示数据库中的键和值,当在 Redis 数据库中新创建一个键值对时至少会创建两个对象,一个对象用作键值对的键(键对象),另一个对象用作键值对的值(值对象

Redis 中对象由一个 redisObject 结构表示,该结构中和保存数据有关的三个属性分别是 type、 encoding、ptr:

1
2
3
4
5
6
7
8
9
10
typedef struct redisObiect {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 指向底层数据结构的指针
void *ptr;

// ....
} robj;

Redis 并没有直接使用数据结构来实现键值对数据库,而是基于这些数据结构创建了一个对象系统,包含字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象,而每种对象又通过不同的编码映射到不同的底层数据结构

Redis 自身是一个 Map,其中所有的数据都是采用 key : value 的形式存储,键对象都是字符串对象,而值对象有五种基本类型和三种高级类型对象

  • 对一个数据库键执行 TYPE 命令,返回的结果为数据库键对应的值对象的类型,而不是键对象的类型
  • 对一个数据库键执行 OBJECT ENCODING 命令,查看数据库键对应的值对象的编码

命令多态

Redis 中用于操作键的命令分为两种类型:

  • 一种命令可以对任何类型的键执行,比如说 DEL 、EXPIRE、RENAME、 TYPE 等(基于类型的多态)
  • 只能对特定类型的键执行,比如 SET 只能对字符串键执行、HSET 对哈希键执行、SADD 对集合键执行,如果类型步匹配会报类型错误: (error) WRONGTYPE Operation against a key holding the wrong kind of value

Redis 为了确保只有指定类型的键可以执行某些特定的命令,在执行类型特定的命令之前,先通过值对象 redisObject 结构 type 属性检查操作类型是否正确,然后再决定是否执行指定的命令

对于多态命令,比如列表对象有 ziplist 和 linkedlist 两种实现方式,通过 redisObject 结构 encoding 属性确定具体的编码类型,底层调用对应的 API 实现具体的操作(基于编码的多态)


内存回收

对象的整个生命周期可以划分为创建对象、 操作对象、 释放对象三个阶段

C 语言没有自动回收内存的功能,所以 Redis 在对象系统中构建了引用计数(reference counting)技术实现的内存回收机制,程序可以跟踪对象的引用计数信息,在适当的时候自动释放对象并进行内存回收

1
2
3
4
typedef struct redisObiect {
// 引用计数
int refcount;
} robj;

对象的引用计数信息会随着对象的使用状态而不断变化,创建时引用计数 refcount 初始化为 1,每次被一个新程序使用时引用计数加 1,当对象不再被一个程序使用时引用计数值会被减 1,当对象的引用计数值变为 0 时,对象所占用的内存会被释放


对象共享

对象的引用计数属性带有对象共享的作用,共享对象机制更节约内存,数据库中保存的相同值对象越多,节约的内存就越多

让多个键共享一个对象的步骤:

  • 将数据库键的值指针指向一个现有的值对象

  • 将被共享的值对象的引用计数增一

Redis 在初始化服务器时创建一万个(配置文件可以修改)字符串对象,包含了从 0 到 9999 的所有整数值,当服务器需要用到值为 0 到 9999 的字符串对象时,服务器就会使用这些共享对象,而不是新创建对象

比如创建一个值为 100 的键 A,并使用 OBJECT REFCOUNT 命令查看键 A 的值对象的引用计数,会发现值对象的引用计数为 2,引用这个值对象的两个程序分别是持有这个值对象的服务器程序,以及共享这个值对象的键 A

共享对象在嵌套了字符串对象的对象(linkedlist 编码的列表、hashtable 编码的哈希、zset 编码的有序集合)中也能使用

Redis 不共享包含字符串对象的原因:验证共享对象和目标对象是否相同的复杂度越高,消耗的 CPU 时间也会越多

  • 整数值的字符串对象, 验证操作的复杂度为 O(1)
  • 字符串值的字符串对象, 验证操作的复杂度为 O(N)
  • 如果共享对象是包含了多个值(或者对象的)对象,比如列表对象或者哈希对象,验证操作的复杂度为 O(N^2)

空转时长

redisObject 结构包含一个 lru 属性,该属性记录了对象最后一次被命令程序访问的时间

1
2
3
typedef struct redisObiect {
unsigned lru:22;
} robj;

OBJECT IDLETIME 命令可以打印出给定键的空转时长,该值就是通过将当前时间减去键的值对象的 lru 时间计算得出的,这个命令在访问键的值对象时,不会修改值对象的 lru 属性

1
2
3
4
5
6
7
8
9
10
11
redis> OBJECT IDLETIME msg
(integer) 10
# 等待一分钟
redis> OBJECT IDLETIME msg
(integer) 70
# 访问 msg
redis> GET msg
"hello world"
# 键处于活跃状态,空转时长为 0
redis> OBJECT IDLETIME msg
(integer) 0

空转时长的作用:如果服务器开启 maxmemory 选项,并且回收内存的算法为 volatile-lru 或者 allkeys-lru,那么当服务器占用的内存数超过了 maxmemory 所设置的上限值时,空转时长较高的那部分键会优先被服务器释放,从而回收内存(LRU 算法)


string

简介

存储的数据:单个数据,最简单的数据存储类型,也是最常用的数据存储类型,实质上是存一个字符串,string 类型是二进制安全的,可以包含任何数据,比如图片或者序列化的对象

存储数据的格式:一个存储空间保存一个数据,每一个空间中只能保存一个字符串信息

存储内容:通常使用字符串,如果字符串以整数的形式展示,可以作为数字操作使用

Redis 所有操作都是原子性的,采用单线程机制,命令是单个顺序执行,无需考虑并发带来影响,原子性就是有一个失败则都失败

字符串对象可以是 int、raw、embstr 三种实现方式


操作

指令操作:

  • 数据操作:

    1
    2
    3
    4
    5
    set key value			#添加/修改数据添加/修改数据
    del key #删除数据
    setnx key value #判定性添加数据,键值为空则设添加
    mset k1 v1 k2 v2... #添加/修改多个数据,m:Multiple
    append key value #追加信息到原始信息后部(如果原始信息存在就追加,否则新建)
  • 查询操作

    1
    2
    3
    get key					#获取数据,如果不存在,返回空(nil)
    mget key1 key2... #获取多个数据
    strlen key #获取数据字符个数(字符串长度)
  • 设置数值数据增加/减少指定范围的值

    1
    2
    3
    4
    5
    incr key					#key++
    incrby key increment #key+increment
    incrbyfloat key increment #对小数操作
    decr key #key--
    decrby key increment #key-increment
  • 设置数据具有指定的生命周期

    1
    2
    setex key seconds value  		#设置key-value存活时间,seconds单位是秒
    psetex key milliseconds value #毫秒级

注意事项:

  1. 数据操作不成功的反馈与数据正常操作之间的差异

    • 表示运行结果是否成功

      • (integer) 0 → false ,失败

      • (integer) 1 → true,成功

    • 表示运行结果值

      • (integer) 3 → 3 个

      • (integer) 1 → 1 个

  2. 数据未获取到时,对应的数据为(nil),等同于null

  3. 数据最大存储量:512MB

  4. string 在 Redis 内部存储默认就是一个字符串,当遇到增减类操作 incr,decr 时会转成数值型进行计算

  5. 按数值进行操作的数据,如果原始数据不能转成数值,或超越了Redis 数值上限范围,将报错
    9223372036854775807(java 中 Long 型数据最大值,Long.MAX_VALUE)

  6. Redis 可用于控制数据库表主键 ID,为数据库表主键提供生成策略,保障数据库表的主键唯一性

单数据和多数据的选择:

  • 单数据执行 3 条指令的过程:3 次发送 + 3 次处理 + 3 次返回
  • 多数据执行 1 条指令的过程:1 次发送 + 3 次处理 + 1 次返回(发送和返回的事件略高于单数据)

对象

字符串对象的编码可以是 int、raw、embstr 三种

  • int:字符串对象保存的是整数值,并且整数值可以用 long 类型来表示,那么对象会将整数值保存在字符串对象结构的 ptr 属性面(将 void * 转换成 long),并将字符串对象的编码设置为 int(浮点数用另外两种方式)

  • raw:字符串对象保存的是一个字符串值,并且值的长度大于 39 字节,那么对象将使用简单动态字符串(SDS)来保存该值,并将对象的编码设置为 raw

  • embstr:字符串对象保存的是一个字符串值,并且值的长度小于等于 39 字节,那么对象将使用 embstr 编码的方式来保存这个字符串值,并将对象的编码设置为 embstr

    上图所示,embstr 与 raw 都使用了 redisObject 和 sdshdr 来表示字符串对象,但是 raw 需要调用两次内存分配函数分别创建两种结构,embstr 只需要一次内存分配来分配一块连续的空间

embstr 是用于保存短字符串的一种编码方式,对比 raw 的优点:

  • 内存分配次数从两次降低为一次,同样释放内存的次数也从两次变为一次
  • embstr 编码的字符串对象的数据都保存在同一块连续内存,所以比 raw 编码能够更好地利用缓存优势(局部性原理)

int 和 embstr 编码的字符串对象在条件满足的情况下,会被转换为 raw 编码的字符串对象:

  • int 编码的整数值,执行 APPEND 命令追加一个字符串值,先将整数值转为字符串然后追加,最后得到一个 raw 编码的对象
  • Redis 没有为 embstr 编码的字符串对象编写任何相应的修改程序,所以 embstr 对象实际上是只读的,执行修改命令会将对象的编码从 embstr 转换成 raw,操作完成后得到一个 raw 编码的对象

某些情况下,程序会将字符串对象里面的字符串值转换回浮点数值,执行某些操作后再将浮点数值转换回字符串值:

1
2
3
4
5
6
7
8
redis> SET pi 3.14 
OK
redis> OBJECT ENCODING pi
"embstr"
redis> INCRBYFLOAT pi 2.0 # 转为浮点数执行增加的操作
"5. 14"
redis> OBJECT ENCODING pi
"embstr"

应用

主页高频访问信息显示控制,例如新浪微博大 V 主页显示粉丝数与微博数量

  • 在 Redis 中为大 V 用户设定用户信息,以用户主键和属性值作为 key,后台设定定时刷新策略

    1
    2
    3
    set user:id:3506728370:fans 12210947
    set user:id:3506728370:blogs 6164
    set user:id:3506728370:focuses 83
  • 使用 JSON 格式保存数据

    1
    user:id:3506728370 → {"fans":12210947,"blogs":6164,"focuses":83}
  • key的设置约定:表名 : 主键名 : 主键值 : 字段名

    表名 主键名 主键值 字段名
    order id 29437595 name
    equip id 390472345 type
    news id 202004150 title

hash

简介

数据存储需求:对一系列存储的数据进行编组,方便管理,典型应用存储对象信息

数据存储结构:一个存储空间保存多个键值对数据

hash 类型:底层使用哈希表结构实现数据存储

Redis 中的 hash 类似于 Java 中的 Map<String, Map<Object,object>>,左边是 key,右边是值,中间叫 field 字段,本质上 hash 存了一个 key-value 的存储空间

hash 是指的一个数据类型,并不是一个数据

  • 如果 field 数量较少,存储结构优化为压缩列表结构(有序)
  • 如果 field 数量较多,存储结构使用 HashMap 结构(无序)

操作

指令操作:

  • 数据操作

    1
    2
    3
    4
    hset key field value		#添加/修改数据
    hdel key field1 [field2] #删除数据,[]代表可选
    hsetnx key field value #设置field的值,如果该field存在则不做任何操作
    hmset key f1 v1 f2 v2... #添加/修改多个数据
  • 查询操作

    1
    2
    3
    4
    5
    hget key field				#获取指定field对应数据
    hgetall key #获取指定key所有数据
    hmget key field1 field2... #获取多个数据
    hexists key field #获取哈希表中是否存在指定的字段
    hlen key #获取哈希表中字段的数量
  • 获取哈希表中所有的字段名或字段值

    1
    2
    hkeys key					#获取所有的field	
    hvals key #获取所有的value
  • 设置指定字段的数值数据增加指定范围的值

    1
    2
    hincrby key field increment		#指定字段的数值数据增加指定的值,increment为负数则减少
    hincrbyfloat key field increment#操作小数

注意事项

  1. hash 类型中 value 只能存储字符串,不允许存储其他数据类型,不存在嵌套现象,如果数据未获取到,对应的值为(nil)
  2. 每个 hash 可以存储 2^32 - 1 个键值对
  3. hash 类型和对象的数据存储形式相似,并且可以灵活添加删除对象属性。但 hash 设计初衷不是为了存储大量对象而设计的,不可滥用,不可将 hash 作为对象列表使用
  4. hgetall 操作可以获取全部属性,如果内部 field 过多,遍历整体数据效率就很会低,有可能成为数据访问瓶颈

实现

哈希对象的内部编码有两种:ziplist(压缩列表)、hashtable(哈希表、字典)

  • 压缩列表实现哈希对象:同一键值对的节点总是挨在一起,保存键的节点在前,保存值的节点在后

  • 字典实现哈希对象:字典的每一个键都是一个字符串对象,每个值也是

当存储的数据量比较小的情况下,Redis 才使用压缩列表来实现字典类型,具体需要满足两个条件:

  • 当键值对数量小于 hash-max-ziplist-entries 配置(默认 512 个)
  • 所有键和值的长度都小于 hash-max-ziplist-value 配置(默认 64 字节)

以上两个条件的上限值是可以通过配置文件修改的,当两个条件的任意一个不能被满足时,对象的编码转换操作就会被执行

ziplist 使用更加紧凑的结构实现多个元素的连续存储,所以在节省内存方面比 hashtable 更加优秀,当 ziplist 无法满足哈希类型时,Redis 会使用 hashtable 作为哈希的内部实现,因为此时 ziplist 的读写效率会下降,而 hashtable 的读写时间复杂度为 O(1)


应用

1
user:id:3506728370 → {"name":"春晚","fans":12210862,"blogs":83}

对于以上数据,使用单条去存的话,存的条数会很多。但如果用 json 格式,存一条数据就够了。

假如现在粉丝数量发生了变化,要把整个值都改变,但是用单条存就不存在这个问题,只需要改其中一个就可以

可以实现购物车的功能,key 对应着每个用户,存储空间存储购物车的信息


list

简介

数据存储需求:存储多个数据,并对数据进入存储空间的顺序进行区分

数据存储结构:一个存储空间保存多个数据,且通过数据可以体现进入顺序,允许重复元素

list 类型:保存多个数据,底层使用双向链表存储结构实现,类似于 LinkedList

如果两端都能存取数据的话,这就是双端队列,如果只能从一端进一端出,这个模型叫栈


操作

指令操作:

  • 数据操作

    1
    2
    3
    4
    5
    lpush key value1 [value2]...#从左边添加/修改数据(表头)
    rpush key value1 [value2]...#从右边添加/修改数据(表尾)
    lpop key #从左边获取并移除第一个数据,类似于出栈/出队
    rpop key #从右边获取并移除第一个数据
    lrem key count value #删除指定数据,count=2删除2个,该value可能有多个(重复数据)
  • 查询操作

    1
    2
    3
    lrange key start stop		#从左边遍历数据并指定开始和结束索引,0是第一个索引,-1是终索引
    lindex key index #获取指定索引数据,没有则为nil,没有索引越界
    llen key #list中数据长度/个数
  • 规定时间内获取并移除数据

    1
    2
    3
    4
    b							#代表阻塞
    blpop key1 [key2] timeout #在指定时间内获取指定key(可以多个)的数据,超时则为(nil)
    #可以从其他客户端写数据,当前客户端阻塞读取数据
    brpop key1 [key2] timeout #从右边操作
  • 复制操作

    1
    brpoplpush source destination timeout	#从source获取数据放入destination,假如在指定时间内没有任何元素被弹出,则返回一个nil和等待时长。反之,返回一个含有两个元素的列表,第一个元素是被弹出元素的值,第二个元素是等待时长

注意事项

  1. list 中保存的数据都是 string 类型的,数据总容量是有限的,最多 2^32 - 1 个元素(4294967295)
  2. list 具有索引的概念,但操作数据时通常以队列的形式进行入队出队,或以栈的形式进行入栈出栈
  3. 获取全部数据操作结束索引设置为 -1
  4. list 可以对数据进行分页操作,通常第一页的信息来自于 list,第 2 页及更多的信息通过数据库的形式加载

实现

在 Redis3.2 版本以前列表对象的内部编码有两种:ziplist(压缩列表)和 linkedlist(链表)

  • 压缩列表实现的列表对象:PUSH 1、three、5 三个元素

  • 链表实现的列表对象:为了简化字符串对象的表示,使用了 StringObject 的结构,底层其实是 sdshdr 结构

列表中存储的数据量比较小的时候,列表就会使用一块连续的内存存储,采用压缩列表的方式实现的条件:

  • 列表对象保存的所有字符串元素的长度都小于 64 字节
  • 列表对象保存的元素数量小于 512 个

以上两个条件的上限值是可以通过配置文件修改的,当两个条件的任意一个不能被满足时,对象的编码转换操作就会被执行

在 Redis3.2 版本 以后对列表数据结构进行了改造,使用 quicklist(快速列表)代替了 linkedlist,quicklist 实际上是 ziplist 和 linkedlist 的混合体,将 linkedlist 按段切分,每一段使用 ziplist 来紧凑存储,多个 ziplist 之间使用双向指针串接起来,既满足了快速的插入删除性能,又不会出现太大的空间冗余


应用

企业运营过程中,系统将产生出大量的运营数据,如何保障多台服务器操作日志的统一顺序输出?

  • 依赖 list 的数据具有顺序的特征对信息进行管理,右进左查或者左近左查
  • 使用队列模型解决多路信息汇总合并的问题
  • 使用栈模型解决最新消息的问题

微信文章订阅公众号:

  • 比如订阅了两个公众号,它们发布了两篇文章,文章 ID 分别为 666 和 888,可以通过执行 LPUSH key 666 888 命令推送给我

set

简介

数据存储需求:存储大量的数据,在查询方面提供更高的效率

数据存储结构:能够保存大量的数据,高效的内部存储机制,便于查询

set 类型:与 hash 存储结构哈希表完全相同,只是仅存储键不存储值(nil),所以添加,删除,查找的复杂度都是 O(1),并且值是不允许重复且无序的


操作

指令操作:

  • 数据操作

    1
    2
    sadd key member1 [member2]	#添加数据
    srem key member1 [member2] #删除数据
  • 查询操作

    1
    2
    3
    smembers key				#获取全部数据
    scard key #获取集合数据总量
    sismember key member #判断集合中是否包含指定数据
  • 随机操作

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
      spop key [count]			#随机获取集中的某个数据并将该数据移除集合
    srandmember key [count] #随机获取集合中指定(数量)的数据

    * 集合的交、并、差

    ```sh
    sinter key1 [key2...] #两个集合的交集,不存在为(empty list or set)
    sunion key1 [key2...] #两个集合的并集
    sdiff key1 [key2...] #两个集合的差集

    sinterstore destination key1 [key2...] #两个集合的交集并存储到指定集合中
    sunionstore destination key1 [key2...] #两个集合的并集并存储到指定集合中
    sdiffstore destination key1 [key2...] #两个集合的差集并存储到指定集合中
  • 复制

    1
    smove source destination member			#将指定数据从原始集合中移动到目标集合中

注意事项

  1. set 类型不允许数据重复,如果添加的数据在 set 中已经存在,将只保留一份
  2. set 虽然与 hash 的存储结构相同,但是无法启用 hash 中存储值的空间

实现

集合对象的内部编码有两种:intset(整数集合)、hashtable(哈希表、字典)

  • 整数集合实现的集合对象:

  • 字典实现的集合对象:键值对的值为 NULL

当集合对象可以同时满足以下两个条件时,对象使用 intset 编码:

  • 集合中的元素都是整数值
  • 集合中的元素数量小于 set-maxintset-entries配置(默认 512 个)

以上两个条件的上限值是可以通过配置文件修改的


应用

应用场景:

  1. 黑名单:资讯类信息类网站追求高访问量,但是由于其信息的价值,往往容易被不法分子利用,通过爬虫技术,快速获取信息,个别特种行业网站信息通过爬虫获取分析后,可以转换成商业机密。

    注意:爬虫不一定做摧毁性的工作,有些小型网站需要爬虫为其带来一些流量。

  2. 白名单:对于安全性更高的应用访问,仅仅靠黑名单是不能解决安全问题的,此时需要设定可访问的用户群体, 依赖白名单做更为苛刻的访问验证

  3. 随机操作可以实现抽奖功能

  4. 集合的交并补可以实现微博共同关注的查看,可以根据共同关注或者共同喜欢推荐相关内容


zset

简介

数据存储需求:数据排序有利于数据的有效展示,需要提供一种可以根据自身特征进行排序的方式

数据存储结构:新的存储模型,可以保存可排序的数据


操作

指令操作:

  • 数据操作

    1
    2
    3
    4
    5
    6
    zadd key score1 member1 [score2 member2]	#添加数据
    zrem key member [member ...] #删除数据
    zremrangebyrank key start stop #删除指定索引范围的数据
    zremrangebyscore key min max #删除指定分数区间内的数据
    zscore key member #获取指定值的分数
    zincrby key increment member #指定值的分数增加increment
  • 查询操作

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    zrange key start stop [WITHSCORES]		#获取指定范围的数据,升序,WITHSCORES 代表显示分数
    zrevrange key start stop [WITHSCORES] #获取指定范围的数据,降序

    zrangebyscore key min max [WITHSCORES] [LIMIT offset count] #按条件获取数据,从小到大
    zrevrangebyscore key max min [WITHSCORES] [...] #从大到小

    zcard key #获取集合数据的总量
    zcount key min max #获取指定分数区间内的数据总量
    zrank key member #获取数据对应的索引(排名)升序
    zrevrank key member #获取数据对应的索引(排名)降序
    • min 与 max 用于限定搜索查询的条件
    • start 与 stop 用于限定查询范围,作用于索引,表示开始和结束索引
    • offset 与 count 用于限定查询范围,作用于查询结果,表示开始位置和数据总量
  • 集合的交、并操作

    1
    2
    zinterstore destination numkeys key [key ...]	#两个集合的交集并存储到指定集合中
    zunionstore destination numkeys key [key ...] #两个集合的并集并存储到指定集合中

注意事项:

  1. score 保存的数据存储空间是 64 位,如果是整数范围是 -9007199254740992~9007199254740992
  2. score 保存的数据也可以是一个双精度的 double 值,基于双精度浮点数的特征可能会丢失精度,慎重使用
  3. sorted_set 底层存储还是基于 set 结构的,因此数据不能重复,如果重复添加相同的数据,score 值将被反复覆盖,保留最后一次修改的结果

实现

有序集合对象的内部编码有两种:ziplist(压缩列表)和 skiplist(跳跃表)

  • 压缩列表实现有序集合对象:ziplist 本身是有序、不可重复的,符合有序集合的特性

  • 跳跃表实现有序集合对象:底层是 zset 结构,zset 同时包含字典和跳跃表的结构,图示字典和跳跃表中重复展示了各个元素的成员和分值,但实际上两者会通过指针来共享相同元素的成员和分值,不会产生空间浪费

    1
    2
    3
    4
    typedef struct zset {
    zskiplist *zsl;
    dict *dict;
    } zset;

使用字典加跳跃表的优势:

  • 字典为有序集合创建了一个从成员到分值的映射,用 O(1) 复杂度查找给定成员的分值
  • 排序操作使用跳跃表完成,节省每次重新排序带来的时间成本和空间成本

使用 ziplist 格式存储需要满足以下两个条件:

  • 有序集合保存的元素个数要小于 128 个;
  • 有序集合保存的所有元素大小都小于 64 字节

当元素比较多时,此时 ziplist 的读写效率会下降,时间复杂度是 O(n),跳表的时间复杂度是 O(logn)


应用

  • 排行榜
  • 对于基于时间线限定的任务处理,将处理时间记录为 score 值,利用排序功能区分处理的先后顺序
  • 当任务或者消息待处理,形成了任务队列或消息队列时,对于高优先级的任务要保障对其优先处理,采用 score 记录权重

Bitmaps

基本操作

Bitmaps 是二进制位数组(bit array),底层使用 SDS 字符串表示,因为 SDS 是二进制安全的

buf 数组的每个字节用一行表示,buf[1] 是 '\0',保存位数组的顺序和书写位数组的顺序是完全相反的,图示的位数组 0100 1101

数据结构的详解查看 Java → Algorithm → 位图


命令实现

GETBIT

GETBIT 命令获取位数组 bitarray 在 offset 偏移量上的二进制位的值

1
GETBIT <bitarray> <offset>

执行过程:

  • 计算 byte = offset/8(向下取整), byte 值记录数据保存在位数组中的索引
  • 计算 bit = (offset mod 8) + 1,bit 值记录数据在位数组中的第几个二进制位
  • 根据 byte 和 bit 值,在位数组 bitarray 中定位 offset 偏移量指定的二进制位,并返回这个位的值

GETBIT 命令执行的所有操作都可以在常数时间内完成,所以时间复杂度为 O(1)


SETBIT

SETBIT 将位数组 bitarray 在 offset 偏移量上的二进制位的值设置为 value,并向客户端返回二进制位的旧值

1
SETBIT <bitarray> <offset> <value> 

执行过程:

  • 计算 len = offset/8 + 1,len 值记录了保存该数据至少需要多少个字节
  • 检查 bitarray 键保存的位数组的长度是否小于 len,成立就会将 SDS 扩展为 len 字节(注意空间预分配机制),所有新扩展空间的二进制位的值置为 0
  • 计算 byte = offset/8(向下取整), byte 值记录数据保存在位数组中的索引
  • 计算 bit = (offset mod 8) + 1,bit 值记录数据在位数组中的第几个二进制位
  • 根据 byte 和 bit 值,在位数组 bitarray 中定位 offset 偏移量指定的二进制位,首先将指定位现存的值保存在 oldvalue 变量,然后将新值 value 设置为这个二进制位的值
  • 向客户端返回 oldvalue 变量的值

BITCOUNT

BITCOUNT 命令用于统计给定位数组中,值为 1 的二进制位的数量

1
BITCOUNT <bitarray> [start end]

二进制位统计算法:

  • 遍历法:遍历位数组中的每个二进制位
  • 查表算法:读取每个字节(8 位)的数据,查表获取数值对应的二进制中有几个 1
  • variable-precision SWAR算法:计算汉明距离
  • Redis 实现:
    • 如果二进制位的数量大于等于 128 位, 那么使用 variable-precision SWAR 算法来计算二进制位的汉明重量
    • 如果二进制位的数量小于 128 位,那么使用查表算法来计算二进制位的汉明重量

BITOP

BITOP 命令对指定 key 按位进行交、并、非、异或操作,并将结果保存到指定的键中

1
BITOP OPTION destKey key1 [key2...]

OPTION 有 AND(与)、OR(或)、 XOR(异或)和 NOT(非)四个选项

AND、OR、XOR 三个命令可以接受多个位数组作为输入,需要遍历输入的每个位数组的每个字节来进行计算,所以命令的复杂度为 O(n^2);与此相反,NOT 命令只接受一个位数组输入,所以时间复杂度为 O(n)


应用场景

  • 解决 Redis 缓存穿透,判断给定数据是否存在, 防止缓存穿透

  • 垃圾邮件过滤,对每一个发送邮件的地址进行判断是否在布隆的黑名单中,如果在就判断为垃圾邮件

  • 爬虫去重,爬给定网址的时候对已经爬取过的 URL 去重

  • 信息状态统计


Hyper

基数是数据集去重后元素个数,HyperLogLog 是用来做基数统计的,运用了 LogLog 的算法

1
2
{1, 3, 5, 7, 5, 7, 8} 	基数集: {1, 3, 5 ,7, 8} 	基数:5
{1, 1, 1, 1, 1, 7, 1} 基数集: {1,7} 基数:2

相关指令:

  • 添加数据

    1
    pfadd key element [element ...]
  • 统计数据

    1
    pfcount key [key ...]
  • 合并数据

    1
    pfmerge destkey sourcekey [sourcekey...]

应用场景:

  • 用于进行基数统计,不是集合不保存数据,只记录数量而不是具体数据,比如网站的访问量
  • 核心是基数估算算法,最终数值存在一定误差
  • 误差范围:基数估计的结果是一个带有 0.81% 标准错误的近似值
  • 耗空间极小,每个 hyperloglog key 占用了12K的内存用于标记基数
  • pfadd 命令不是一次性分配12K内存使用,会随着基数的增加内存逐渐增大
  • Pfmerge 命令合并后占用的存储空间为12K,无论合并之前数据量多少

GEO

GeoHash 是一种地址编码方法,把二维的空间经纬度数据编码成一个字符串

  • 添加坐标点

    1
    2
    geoadd key longitude latitude member [longitude latitude member ...]
    georadius key longitude latitude radius m|km|ft|mi [withcoord] [withdist] [withhash] [count count]
  • 获取坐标点

    1
    2
    geopos key member [member ...]
    georadiusbymember key member radius m|km|ft|mi [withcoord] [withdist] [withhash] [count count]
  • 计算距离

    1
    2
    geodist key member1 member2 [unit]	#计算坐标点距离
    geohash key member [member ...] #计算经纬度

Redis 应用于地理位置计算


持久机制

概述

持久化:利用永久性存储介质将数据进行保存,在特定的时间将保存的数据进行恢复的工作机制称为持久化

作用:持久化用于防止数据的意外丢失,确保数据安全性,因为 Redis 是内存级,所以需要持久化到磁盘

计算机中的数据全部都是二进制,保存一组数据有两种方式

RDB:将当前数据状态进行保存,快照形式,存储数据结果,存储格式简单

AOF:将数据的操作过程进行保存,日志形式,存储操作过程,存储格式复杂


RDB

文件创建

RDB 持久化功能所生成的 RDB 文件 是一个经过压缩的紧凑二进制文件,通过该文件可以还原生成 RDB 文件时的数据库状态,有两个 Redis 命令可以生成 RDB 文件,一个是 SAVE,另一个是 BGSAVE

SAVE

SAVE 指令:手动执行一次保存操作,该指令的执行会阻塞当前 Redis 服务器,客户端发送的所有命令请求都会被拒绝,直到当前 RDB 过程完成为止,有可能会造成长时间阻塞,线上环境不建议使用

工作原理:Redis 是个单线程的工作模式,会创建一个任务队列,所有的命令都会进到这个队列排队执行。当某个指令在执行的时候,队列后面的指令都要等待,所以这种执行方式会非常耗时

配置 redis.conf:

1
2
3
4
dir path				#设置存储.rdb文件的路径,通常设置成存储空间较大的目录中,目录名称data
dbfilename "x.rdb" #设置本地数据库文件名,默认值为dump.rdb,通常设置为dump-端口号.rdb
rdbcompression yes|no #设置存储至本地数据库时是否压缩数据,默认yes,设置为no节省CPU运行时间
rdbchecksum yes|no #设置读写文件过程是否进行RDB格式校验,默认yes

BGSAVE

BGSAVE:bg 是 background,代表后台执行,命令的完成需要两个进程,进程之间不相互影响,所以持久化期间 Redis 正常工作

工作原理:

流程:客户端发出 BGSAVE 指令,Redis 服务器使用 fork 函数创建一个子进程,然后响应后台已经开始执行的信息给客户端。子进程会异步执行持久化的操作,持久化过程是先将数据写入到一个临时文件中,持久化操作结束再用这个临时文件替换上次持久化的文件

1
2
3
4
5
6
7
8
9
10
11
12
13
# 创建子进程
pid = fork()
if pid == 0:
# 子进程负责创建 RDB 文件
rdbSave()
# 完成之后向父进程发送信号
signal_parent()
elif pid > 0:
# 父进程继续处理命令请求,并通过轮询等待子进程的信号
handle_request_and_wait_signal()
else:
# 处理出错恃况
handle_fork_error()

配置 redis.conf

1
2
3
4
5
stop-writes-on-bgsave-error yes|no	#后台存储过程中如果出现错误,是否停止保存操作,默认yes
dbfilename filename
dir path
rdbcompression yes|no
rdbchecksum yes|no

注意:BGSAVE 命令是针对 SAVE 阻塞问题做的优化,Redis 内部所有涉及到 RDB 操作都采用 BGSAVE 的方式,SAVE 命令放弃使用

在 BGSAVE 命令执行期间,服务器处理 SAVE、BGSAVE、BGREWRITEAOF 三个命令的方式会和平时有所不同

  • SAVE 命令会被服务器拒绝,服务器禁止 SAVE 和 BGSAVE 命令同时执行是为了避免父进程(服务器进程)和子进程同时执行两个 rdbSave 调用,产生竞争条件
  • BGSAVE 命令也会被服务器拒绝,也会产生竞争条件
  • BGREWRITEAOF 和 BGSAVE 两个命令不能同时执行
    • 如果 BGSAVE 命令正在执行,那么 BGREWRITEAOF 命令会被延迟到 BGSAVE 命令执行完毕之后执行
    • 如果 BGREWRITEAOF 命令正在执行,那么 BGSAVE 命令会被服务器拒绝

特殊指令

RDB 特殊启动形式的指令(客户端输入)

  • 服务器运行过程中重启

    1
    debug reload
  • 关闭服务器时指定保存数据

    1
    shutdown save

    默认情况下执行 shutdown 命令时,自动执行 bgsave(如果没有开启 AOF 持久化功能)

  • 全量复制:主从复制部分详解


文件载入

RDB 文件的载入工作是在服务器启动时自动执行,期间 Redis 会一直处于阻塞状态,直到载入完成

Redis 并没有专门用于载入 RDB 文件的命令,只要服务器在启动时检测到 RDB 文件存在,就会自动载入 RDB 文件

1
[7379] 30 Aug 21:07:01.289 * DB loaded from disk: 0.018 seconds  # 服务器在成功载入 RDB 文件之后打印

AOF 文件的更新频率通常比 RDB 文件的更新频率高:

  • 如果服务器开启了 AOF 持久化功能,那么会优先使用 AOF 文件来还原数据库状态
  • 只有在 AOF 持久化功能处于关闭状态时,服务器才会使用 RDB 文件来还原数据库状态

自动保存

配置文件

Redis 支持通过配置服务器的 save 选项,让服务器每隔一段时间自动执行一次 BGSAVE 命令

配置 redis.conf:

1
save second changes #设置自动持久化条件,满足限定时间范围内key的变化数量就进行持久化(bgsave)
  • second:监控时间范围
  • changes:监控 key 的变化量

默认三个条件:

1
2
3
save 900 1		# 900s内1个key发生变化就进行持久化
save 300 10
save 60 10000

判定 key 变化的依据:

  • 对数据产生了影响,不包括查询
  • 不进行数据比对,比如 name 键存在,重新 set name seazean 也算一次变化

save 配置要根据实际业务情况进行设置,频度过高或过低都会出现性能问题,结果可能是灾难性的


自动原理

服务器状态相关的属性:

1
2
3
4
5
6
7
8
9
10
struct redisServer {
// 记录了保存条件的数组
struct saveparam *saveparams;

// 修改计数器
long long dirty;

// 上一次执行保存的时间
time_t lastsave;
};
  • Redis 服务器启动时,可以通过指定配置文件或者传入启动参数的方式设置 save 选项, 如果没有自定义就设置为三个默认值(上节提及),设置服务器状态 redisServe.saveparams 属性,该数组每一项为一个 saveparam 结构,代表 save 的选项设置

    1
    2
    3
    4
    5
    6
    struct saveparam {
    // 秒数
    time_t seconds
    // 修改数
    int changes;
    };
  • dirty 计数器记录距离上一次成功执行 SAVE 或者 BGSAVE 命令之后,服务器中的所有数据库进行了多少次修改(包括写入、删除、更新等操作),当服务器成功执行一个修改指令,该命令修改了多少次数据库, dirty 的值就增加多少

  • lastsave 属性是一个 UNIX 时间戳,记录了服务器上一次成功执行 SAVE 或者 BGSAVE 命令的时间

Redis 的服务器周期性操作函数 serverCron 默认每隔 100 毫秒就会执行一次,该函数用于对正在运行的服务器进行维护

serverCron 函数的其中一项工作是检查 save 选项所设置的保存条件是否满足,会遍历 saveparams 数组中的所有保存条件,只要有任意一个条件被满足服务器就会执行 BGSAVE 命令


文件结构

RDB 的存储结构:图示全大写单词标示常量,用全小写单词标示变量和数据

  • REDIS:长度为 5 字节,保存着 REDIS 五个字符,是 RDB 文件的开头,在载入文件时可以快速检查所载入的文件是否 RDB 文件
  • db_version:长度为 4 字节,是一个用字符串表示的整数,记录 RDB 的版本号
  • database:包含着零个或任意多个数据库,以及各个数据库中的键值对数据
  • EOF:长度为 1 字节的常量,标志着 RDB 文件正文内容的结束,当读入遇到这个值时,代表所有数据库的键值对都已经载入完毕
  • check_sum:长度为 8 字节的无符号整数,保存着一个校验和,该值是通过 REDIS、db_version、databases、EOF 四个部分的内容进行计算得出。服务器在载入 RDB 文件时,会将载入数据所计算出的校验和与 check_sum 所记录的校验和进行对比,来检查 RDB 文件是否有出错或者损坏

Redis 本身带有 RDB 文件检查工具 redis-check-dump


AOF

基本概述

AOF(append only file)持久化:以独立日志的方式记录每次写命令(不记录读)来记录数据库状态,增量保存只许追加文件但不可以改写文件,与 RDB 相比可以理解为由记录数据改为记录数据的变化

AOF 主要作用是解决了数据持久化的实时性,目前已经是 Redis 持久化的主流方式

AOF 写数据过程:

Redis 只会将对数据库进行了修改的命令写入到 AOF 文件,并复制到各个从服务器,但是 PUBSUB 和 SCRIPT LOAD 命令例外:

  • PUBSUB 命令虽然没有修改数据库,但 PUBSUB 命令向频道的所有订阅者发送消息这一行为带有副作用,接收到消息的所有客户端的状态都会因为这个命令而改变,所以服务器需要使用 REDIS_FORCE_AOF 标志强制将这个命令写入 AOF 文件。这样在将来载入 AOF 文件时,服务器就可以再次执行相同的 PUBSUB 命令,并产生相同的副作用
  • SCRIPT LOAD 命令虽然没有修改数据库,但它修改了服务器状态,所以也是一个带有副作用的命令,需要使用 REDIS_FORCE_AOF

持久实现

AOF 持久化功能的实现可以分为命令追加(append)、文件写入、文件同步(sync)三个步骤

命令追加

启动 AOF 的基本配置:

1
2
3
appendonly yes|no				#开启AOF持久化功能,默认no,即不开启状态
appendfilename filename #AOF持久化文件名,默认appendonly.aof,建议设置appendonly-端口号.aof
dir #AOF持久化文件保存路径,与RDB持久化文件路径保持一致即可

当 AOF 持久化功能处于打开状态时,服务器在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器状态的 aof_buf 缓冲区的末尾

1
2
3
4
struct redisServer {
// AOF 缓冲区
sds aof_buf;
};

文件写入

服务器在处理文件事件时会执行写命令,追加一些内容到 aof_buf 缓冲区里,所以服务器每次结束一个事件循环之前,就会执行 flushAppendOnlyFile 函数,判断是否需要将 aof_buf 缓冲区中的内容写入和保存到 AOF 文件

flushAppendOnlyFile 函数的行为由服务器配置的 appendfsync 选项的值来决定

1
appendfsync always|everysec|no	#AOF写数据策略:默认为everysec
  • always:每次写入操作都将 aof_buf 缓冲区中的所有内容写入并同步到 AOF 文件

    特点:安全性最高,数据零误差,但是性能较低,不建议使用

  • everysec:先将 aof_buf 缓冲区中的内容写入到 AOF 文件,判断上次同步 AOF 文件的时间距离现在超过一秒钟,再次对 AOF 文件进行同步,这个同步操作是由一个(子)线程专门负责执行的

    特点:在系统突然宕机的情况下丢失 1 秒内的数据,准确性较高,性能较高,建议使用,也是默认配置

  • no:将 aof_buf 缓冲区中的内容写入到 AOF 文件,但并不对 AOF 文件进行同步,何时同步由操作系统来决定

    特点:整体不可控,服务器宕机会丢失上次同步 AOF 后的所有写指令


文件同步

在现代操作系统中,当用户调用 write 函数将数据写入文件时,操作系统通常会将写入数据暂时保存在一个内存缓冲区空间,等到缓冲区写满或者到达特定时间周期,才真正地将缓冲区中的数据写入到磁盘里面(刷脏)

  • 优点:提高文件的写入效率
  • 缺点:为写入数据带来了安全问题,如果计算机发生停机,那么保存在内存缓冲区里面的写入数据将会丢失

系统提供了 fsync 和 fdatasync 两个同步函数做强制硬盘同步,可以让操作系统立即将缓冲区中的数据写入到硬盘里面,函数会阻塞到写入硬盘完成后返回,保证了数据持久化

异常恢复:AOF 文件损坏,通过 redis-check-aof–fix appendonly.aof 进行恢复,重启 Redis,然后重新加载


文件载入

AOF 文件里包含了重建数据库状态所需的所有写命令,所以服务器只要读入并重新执行一遍 AOF 文件里的命令,就还原服务器关闭之前的数据库状态,服务器在启动时,还原数据库状态打印的日志:

1
[8321] 05 Sep 11:58:50.449 * DB loaded from append only file: 0.000 seconds 

AOF 文件里面除了用于指定数据库的 SELECT 命令是服务器自动添加的,其他都是通过客户端发送的命令

1
2
3
* 2\r\n$6\r\nSELECT\r\n$1\r\n0\r\n	# 服务器自动添加
* 3\r\n$3\r\nSET\r\n$3\r\nmsg\r\n$5\r\nhello\r\n
* 5\r\n$4\r\nSADD\r\n$6\r\nfruits\r\n$5\r\napple\r\n$6\r\nbanana\r\n$6\r\ncherry\r\n

Redis 读取 AOF 文件并还原数据库状态的步骤:

  • 创建一个不带网络连接的伪客户端(fake client)执行命令,因为 Redis 的命令只能在客户端上下文中执行, 而载入 AOF 文件时所使用的命令来源于本地 AOF 文件而不是网络连接
  • 从 AOF 文件分析并读取一条写命令
  • 使用伪客户端执行被读出的写命令,然后重复上述步骤

重写实现

重写策略

AOF 重写:读取服务器当前的数据库状态,生成新 AOF 文件来替换旧 AOF 文件,不会对现有的 AOF 文件进行任何读取、分析或者写入操作,而是直接原子替换。新 AOF 文件不会包含任何浪费空间的冗余命令,所以体积通常会比旧 AOF 文件小得多

AOF 重写规则:

  • 进程内具有时效性的数据,并且数据已超时将不再写入文件

  • 对同一数据的多条写命令合并为一条命令,因为会读取当前的状态,所以直接将当前状态转换为一条命令即可。为防止数据量过大造成客户端缓冲区溢出,对 list、set、hash、zset 等集合类型,单条指令最多写入 64 个元素

    如 lpushlist1 a、lpush list1 b、lpush list1 c 可以转化为:lpush list1 a b c

  • 非写入类的无效指令将被忽略,只保留最终数据的写入命令,但是 select 指令虽然不更改数据,但是更改了数据的存储位置,此类命令同样需要记录

AOF 重写作用:

  • 降低磁盘占用量,提高磁盘利用率
  • 提高持久化效率,降低持久化写时间,提高 IO 性能
  • 降低数据恢复的用时,提高数据恢复效率

重写原理

AOF 重写程序 aof_rewrite 函数可以创建一个新 AOF 文件, 但是该函数会进行大量的写入操作,调用这个函数的线程将被长时间阻塞,所以 Redis 将 AOF 重写程序放到 fork 的子进程里执行,不会阻塞父进程,重写命令:

1
bgrewriteaof
  • 子进程进行 AOF 重写期间,服务器进程(父进程)可以继续处理命令请求

  • 子进程带有服务器进程的数据副本,使用子进程而不是线程,可以在避免使用锁的情况下, 保证数据的安全性

子进程在进行 AOF 重写期间,服务器进程还需要继续处理命令请求,而新命令可能会对现有的数据库状态进行修改,从而使得服务器当前的数据库状态和重写后的 AOF 文件所保存的数据库状态不一致,所以 Redis 设置了 AOF 重写缓冲区

工作流程:

  • Redis 服务器执行完一个写命令,会同时将该命令追加到 AOF 缓冲区和 AOF 重写缓冲区(从创建子进程后才开始写入)
  • 当子进程完成 AOF 重写工作之后,会向父进程发送一个信号,父进程在接到该信号之后, 会调用一个信号处理函数,该函数执行时会对服务器进程(父进程)造成阻塞(影响很小,类似 JVM STW),主要工作:
    • 将 AOF 重写缓冲区中的所有内容写入到新 AOF 文件中, 这时新 AOF 文件所保存的状态将和服务器当前的数据库状态一致
    • 对新的 AOF 文件进行改名,原子地(atomic)覆盖现有的 AOF 文件,完成新旧两个 AOF 文件的替换

自动重写

触发时机:Redis 会记录上次重写时的 AOF 大小,默认配置是当 AOF 文件大小是上次重写后大小的一倍且文件大于 64M 时触发

1
2
auto-aof-rewrite-min-size size		#设置重写的基准值,最小文件 64MB,达到这个值开始重写
auto-aof-rewrite-percentage percent #触发AOF文件执行重写的增长率,当前AOF文件大小超过上一次重写的AOF文件大小的百分之多少才会重写,比如文件达到 100% 时开始重写就是两倍时触发

自动重写触发比对参数( 运行指令 info Persistence 获取具体信息 ):

1
2
aof_current_size					#AOF文件当前尺寸大小(单位:字节)
aof_base_size #AOF文件上次启动和重写时的尺寸大小(单位:字节)

自动重写触发条件公式:

  • aof_current_size > auto-aof-rewrite-min-size
  • (aof_current_size - aof_base_size) / aof_base_size >= auto-aof-rewrite-percentage

对比

RDB 的特点

  • RDB 优点:

    • RDB 是一个紧凑压缩的二进制文件,存储效率较高,但存储数据量较大时,存储效率较低
    • RDB 内部存储的是 Redis 在某个时间点的数据快照,非常适合用于数据备份,全量复制、灾难恢复
    • RDB 恢复数据的速度要比 AOF 快很多,因为是快照,直接恢复
  • RDB 缺点:

    • BGSAVE 指令每次运行要执行 fork 操作创建子进程,会牺牲一些性能
    • RDB 方式无论是执行指令还是利用配置,无法做到实时持久化,具有丢失数据的可能性,最后一次持久化后的数据可能丢失
    • Redis 的众多版本中未进行 RDB 文件格式的版本统一,可能出现各版本之间数据格式无法兼容

AOF 特点:

  • AOF 的优点:数据持久化有较好的实时性,通过 AOF 重写可以降低文件的体积
  • AOF 的缺点:文件较大时恢复较慢

AOF 和 RDB 同时开启,系统默认取 AOF 的数据(数据不会存在丢失)

应用场景:

  • 对数据非常敏感,建议使用默认的 AOF 持久化方案,AOF 持久化策略使用 everysecond,每秒钟 fsync 一次,该策略 Redis 仍可以保持很好的处理性能

    注意:AOF 文件存储体积较大,恢复速度较慢,因为要执行每条指令

  • 数据呈现阶段有效性,建议使用 RDB 持久化方案,可以做到阶段内无丢失,且恢复速度较快

    注意:利用 RDB 实现紧凑的数据持久化,存储数据量较大时,存储效率较低

综合对比:

  • RDB 与 AOF 的选择实际上是在做一种权衡,每种都有利有弊
  • 灾难恢复选用 RDB
  • 如不能承受数分钟以内的数据丢失,对业务数据非常敏感,选用 AOF;如能承受数分钟以内的数据丢失,且追求大数据集的恢复速度,选用 RDB
  • 双保险策略,同时开启 RDB 和 AOF,重启后 Redis 优先使用 AOF 来恢复数据,降低丢失数据的量
  • 不建议单独用 AOF,因为可能会出现 Bug,如果只是做纯内存缓存,可以都不用

fork

介绍

fork() 函数创建一个子进程,子进程与父进程几乎是完全相同的进程,系统先给子进程分配资源,然后把父进程的所有数据都复制到子进程中,只有少数值与父进程的值不同,相当于克隆了一个进程

在完成对其调用之后,会产生 2 个进程,且每个进程都会从 fork() 的返回处开始执行,这两个进程将执行相同的程序段,但是拥有各自不同的堆段,栈段,数据段,每个子进程都可修改各自的数据段,堆段,和栈段

1
2
3
#include<unistd.h>
pid_t fork(void);
// 父进程返回子进程的pid,子进程返回0,错误返回负值,根据返回值的不同进行对应的逻辑处理

fork 调用一次,却能够返回两次,可能有三种不同的返回值:

  • 在父进程中,fork 返回新创建子进程的进程 ID
  • 在子进程中,fork 返回 0
  • 如果出现错误,fork 返回一个负值,错误原因:
    • 当前的进程数已经达到了系统规定的上限,这时 errno 的值被设置为 EAGAIN
    • 系统内存不足,这时 errno 的值被设置为 ENOMEM

fpid 的值在父子进程中不同:进程形成了链表,父进程的 fpid 指向子进程的进程 id,因为子进程没有子进程,所以其 fpid 为0

创建新进程成功后,系统中出现两个基本完全相同的进程,这两个进程执行没有固定的先后顺序,哪个进程先执行要看系统的调度策略

每个进程都有一个独特(互不相同)的进程标识符 process ID,可以通过 getpid() 函数获得;还有一个记录父进程 pid 的变量,可以通过 getppid() 函数获得变量的值


使用

基本使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <unistd.h>  
#include <stdio.h>
int main ()
{
pid_t fpid; // fpid表示fork函数返回的值
int count = 0;
fpid = fork();
if (fpid < 0)
printf("error in fork!");
else if (fpid == 0) {
printf("i am the child process, my process id is %d/n", getpid());
count++;
}
else {
printf("i am the parent process, my process id is %d/n", getpid());
count++;
}
printf("count: %d/n",count);// 1
return 0;
}
/* 输出内容:
i am the child process, my process id is 5574
count: 1
i am the parent process, my process id is 5573
count: 1
*/

进阶使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <unistd.h>  
#include <stdio.h>
int main(void)
{
int i = 0;
// ppid 指当前进程的父进程pid
// pid 指当前进程的pid,
// fpid 指fork返回给当前进程的值,在这可以表示子进程
for(i = 0; i < 2; i++){
pid_t fpid = fork();
if(fpid == 0)
printf("%d child %4d %4d %4d/n",i, getppid(), getpid(), fpid);
else
printf("%d parent %4d %4d %4d/n",i, getppid(), getpid(),fpid);
}
return 0;
}
/*输出内容:
i 父id id 子id
0 parent 2043 3224 3225
0 child 3224 3225 0
1 parent 2043 3224 3226
1 parent 3224 3225 3227
1 child 1 3227 0
1 child 1 3226 0
*/

在 p3224 和 p3225 执行完第二个循环后,main 函数退出,进程死亡。所以 p3226,p3227 就没有父进程了,成为孤儿进程,所以 p3226 和 p3227 的父进程就被置为 ID 为 1的 init 进程(笔记 Tool → Linux → 进程管理详解)

参考文章:https://blog.csdn.net/love_gaohz/article/details/41727415


内存

fork() 调用之后父子进程的内存关系

早期 Linux 的 fork() 实现时,就是全部复制,这种方法效率太低,而且造成了很大的内存浪费,现在 Linux 实现采用了两种方法:

  • 父子进程的代码段是相同的,所以代码段是没必要复制的,只需内核将代码段标记为只读,父子进程就共享此代码段。fork() 之后在进程创建代码段时,子进程的进程级页表项都指向和父进程相同的物理页帧

  • 对于父进程的数据段,堆段,栈段中的各页,由于父子进程相互独立,采用写时复制 COW 的技术,来提高内存以及内核的利用率

    在 fork 之后两个进程用的是相同的物理空间(内存区),子进程的代码段、数据段、堆栈都是指向父进程的物理空间,两者的虚拟空间不同,但其对应的物理空间是同一个,当父子进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间。如果两者的代码完全相同,代码段继续共享父进程的物理空间;而如果两者执行的代码不同,子进程的代码段也会分配单独的物理空间。

    fork 之后内核会将子进程放在队列的前面,让子进程先执行,以免父进程执行导致写时复制,而后子进程再执行,因无意义的复制而造成效率的下降

补充知识:

vfork(虚拟内存 fork virtual memory fork):调用 vfork() 父进程被挂起,子进程使用父进程的地址空间。不采用写时复制,如果子进程修改父地址空间的任何页面,这些修改过的页面对于恢复的父进程是可见的

参考文章:https://blog.csdn.net/Shreck66/article/details/47039937


事务机制

事务特征

Redis 事务就是将多个命令请求打包,然后一次性、按顺序地执行多个命令的机制,并且在事务执行期间,服务器不会中断事务去执行其他的命令请求,会将事务中的所有命令都执行完毕,然后才去处理其他客户端的命令请求,Redis 事务的特性:

  • Redis 事务没有隔离级别的概念,队列中的命令在事务没有提交之前都不会实际被执行
  • Redis 单条命令式保存原子性的,但是事务不保证原子性,事务中如果有一条命令执行失败,其后的命令仍然会被执行,没有回滚

工作流程

事务的执行流程分为三个阶段:

  • 事务开始:MULTI 命令的执行标志着事务的开始,通过在客户端状态的 flags 属性中打开 REDIS_MULTI 标识,将执行该命令的客户端从非事务状态切换至事务状态

    1
    MULTI	# 设定事务的开启位置,此指令执行后,后续的所有指令均加入到事务中
  • 命令入队:事务队列以先进先出(FIFO)的方式保存入队的命令,每个 Redis 客户端都有事务状态,包含着事务队列:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    typedef struct redisClient {
    // 事务状态
    multiState mstate; /* MULTI/EXEC state */
    }

    typedef struct multiState {
    // 事务队列,FIFO顺序
    multiCmd *commands;

    // 已入队命令计数
    int count;
    }
    • 如果命令为 EXEC、DISCARD、WATCH、MULTI 四个命中的一个,那么服务器立即执行这个命令
    • 其他命令服务器不执行,而是将命令放入一个事务队列里面,然后向客户端返回 QUEUED 回复
  • 事务执行:EXEC 提交事务给服务器执行,服务器会遍历这个客户端的事务队列,执行队列中的命令并将执行结果返回

    1
    EXEC	# Commit 提交,执行事务,与multi成对出现,成对使用

事务取消的方法:

  • 取消事务:

    1
    DISCARD	# 终止当前事务的定义,发生在multi之后,exec之前

    一般用于事务执行过程中输入了错误的指令,直接取消这次事务,类似于回滚


WATCH

监视机制

WATCH 命令是一个乐观锁(optimistic locking),可以在 EXEC 命令执行之前,监视任意数量的数据库键,并在 EXEC 命令执行时,检查被监视的键是否至少有一个已经被修改过了,如果是服务器将拒绝执行事务,并向客户端返回代表事务执行失败的空回复

  • 添加监控锁

    1
    WATCH key1 [key2……]	#可以监控一个或者多个key
  • 取消对所有 key 的监视

    1
    UNWATCH

实现原理

每个 Redis 数据库都保存着一个 watched_keys 字典,键是某个被 WATCH 监视的数据库键,值则是一个链表,记录了所有监视相应数据库键的客户端:

1
2
3
4
typedef struct redisDb {
// 正在被 WATCH 命令监视的键
dict *watched_keys;
}

所有对数据库进行修改的命令,在执行后都会调用 multi.c/touchWatchKey 函数对 watched_keys 字典进行检查,是否有客户端正在监视刚被命令修改过的数据库键,如果有的话函数会将监视被修改键的客户端的 REDIS_DIRTY_CAS 标识打开,表示该客户端的事务安全性已经被破坏

服务器接收到个客户端 EXEC 命令时,会根据这个客户端是否打开了 REDIS_DIRTY_CAS 标识,如果打开了说明客户端提交事务不安全,服务器会拒绝执行


ACID

原子性

事务具有原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)

原子性指事务队列中的命令要么就全部都执行,要么一个都不执行,但是在命令执行出错时,不会保证原子性(下一节详解)

Redis 不支持事务回滚机制(rollback),即使事务队列中的某个命令在执行期间出现了错误,整个事务也会继续执行下去,直到将事务队列中的所有命令都执行完毕为止

回滚需要程序员在代码中实现,应该尽可能避免:

  • 事务操作之前记录数据的状态

    • 单数据:string

    • 多数据:hash、list、set、zset

  • 设置指令恢复所有的被修改的项

    • 单数据:直接 set(注意周边属性,例如时效)

    • 多数据:修改对应值或整体克隆复制


一致性

事务具有一致性指的是,数据库在执行事务之前是一致的,那么在事务执行之后,无论事务是否执行成功,数据库也应该仍然是一致的

一致是数据符合数据库的定义和要求,没有包含非法或者无效的错误数据,Redis 通过错误检测和简单的设计来保证事务的一致性:

  • 入队错误:命令格式输入错误,出现语法错误造成,整体事务中所有命令均不会执行,包括那些语法正确的命令

  • 执行错误:命令执行出现错误,例如对字符串进行 incr 操作,事务中正确的命令会被执行,运行错误的命令不会被执行

  • 服务器停机:

    • 如果服务器运行在无持久化的内存模式下,那么重启之后的数据库将是空白的,因此数据库是一致的
    • 如果服务器运行在持久化模式下,重启之后将数据库还原到一致的状态

隔离性

Redis 是一个单线程的执行原理,所以对于隔离性,分以下两种情况:

  • 并发操作在 EXEC 命令前执行,隔离性的保证要使用 WATCH 机制来实现,否则隔离性无法保证
  • 并发操作在 EXEC 命令后执行,隔离性可以保证

持久性

Redis 并没有为事务提供任何额外的持久化功能,事务的持久性由 Redis 所使用的持久化模式决定

配置选项 no-appendfsync-on-rewrite 可以配合 appendfsync 选项在 AOF 持久化模式使用:

  • 选项打开时在执行 BGSAVE 或者 BGREWRITEAOF 期间,服务器会暂时停止对 AOF 文件进行同步,从而尽可能地减少 I/O 阻塞
  • 选项打开时运行在 always 模式的 AOF 持久化,事务也不具有持久性,所以该选项默认关闭

在一个事务的最后加上 SAVE 命令总可以保证事务的耐久性


Lua 脚本

环境创建

基本介绍

Redis 对 Lua 脚本支持,通过在服务器中嵌入 Lua 环境,客户端可以使用 Lua 脚本直接在服务器端原子地执行多个命令

1
2
EVAL <script> <numkeys> [key ...] [arg ...]
EVALSHA <sha1> <numkeys> [key ...] [arg ...]

EVAL 命令可以直接对输入的脚本计算:

1
2
redis> EVAL "return 1 + 1" 0	# 0代表需要的参数
(integer) 2

EVALSHA 命令根据脚本的 SHA1 校验和来对脚本计算:

1
2
redis> EVALSHA "2f3lba2bb6d6a0f42ccl59d2e2dad55440778de3" 0
(integer) 2

应用场景:Redis 只保证单条命令的原子性,所以为了实现原子操作,将多条的对 Redis 的操作整合到一个脚本里,但是避免把不需要做并发控制的操作写入脚本中

Lua 语法特点:

  • 声明变量的时候无需指定数据类型,而是用 local 来声明变量为局部变量
  • 数组下标是从 1 开始

创建过程

Redis 服务器创建并修改 Lua 环境的整个过程:

  • 创建一个基础的 Lua 环境,调用 Lua 的 API 函数 lua_open

  • 载入多个函数库到 Lua 环境里面,让 Lua 脚本可以使用这些函数库来进行数据操作,包括基础核心函数

  • 创建全局变量 redis 表格,表格包含以下函数:

    • 执行 Redis 命令的 redis.call 和 redis.pcall 函数
    • 记录 Redis 日志的 redis.log 函数,以及相应的日志级别 (level) 常量 redis.LOG_DEBUG 等
    • 计算 SHAl 校验和的 redis.shalhex 函数
    • 返回错误信息的 redis.error_reply 函数和 redis.status_reply 函数
  • 使用 Redis 自制的随机函数来替换 Lua 原有的带有副作用的随机函数,从而避免在脚本中引入副作用

    Redis 要求所有传入服务器的 Lua 脚本,以及 Lua 环境中的所有函数,都必须是无副作用(side effect)的纯函数(pure function),所以对有副作用的随机函数 math.randommath.randornseed 进行替换

  • 创建排序辅助函数 _redis_compare_helper,使用辅助函数来对一部分 Redis 命令的结果进行排序,从而消除命令的不确定性

    比如集合元素的排列是无序的, 所以即使两个集合的元素完全相同,输出结果也不一定相同,Redis 将 SMEMBERS 这类在相同数据集上产生不同输出的命令称为带有不确定性的命令

  • 创建 redis.pcall 函数的错误报告辅助函数 _redis_err_handler ,这个函数可以打印出错代码的来源和发生错误的行数

  • 对 Lua 环境中的全局环境进行保护,确保传入服务器的脚本不会因忘记使用 local 关键字,而将额外的全局变量添加到 Lua 环境

  • 将完成修改的 Lua 环境保存到服务器状态的 lua 属性中,等待执行服务器传来的 Lua 脚本

    1
    2
    3
    struct redisServer {
    Lua *lua;
    };

Redis 使用串行化的方式来执行 Redis 命令,所以在任何时间里最多都只会有一个脚本能够被放进 Lua 环境里面运行,因此整个 Redis 服务器只需要创建一个 Lua 环境即可


协作组件

伪客户端

Redis 服务器为 Lua 环境创建了一个伪客户端负责处理 Lua 脚本中包含的所有 Redis 命令,工作流程:

  • Lua 环境将 redis.call 或者 redis.pcall 函数想要执行的命令传给伪客户端
  • 伪客户端将命令传给命令执行器
  • 命令执行器执行命令并将命令的执行结果返回给伪客户端
  • 伪客户端接收命令执行器返回的命令结果,并将结果返回给 Lua 环境
  • Lua 将命令结果返回给 redis.call 函数或者 redis.pcall 函数
  • redis.call 函数或者 redis.pcall 函数会将命令结果作为返回值返回给脚本的调用者


脚本字典

Redis 服务器为 Lua 环境创建 lua_scripts 字典,键为某个 Lua 脚本的 SHA1 校验和(checksum),值则是校验和对应的 Lua 脚本

1
2
3
struct redisServer {
dict *lua_scripts;
};

服务器会将所有被 EVAL 命令执行过的 Lua 脚本,以及所有被 SCRIPT LOAD 命令载入过的 Lua 脚本都保存到 lua_scripts 字典

1
2
redis> SCRIPT LOAD "return 'hi'"
"2f3lba2bb6d6a0f42ccl59d2e2dad55440778de3" # 字典的键,SHA1 校验和

命令实现

脚本函数

EVAL 命令的执行的第一步是为传入的脚本定义一个相对应的 Lua 函数,Lua 函数的名字由 f_ 前缀加上脚本的 SHA1 校验和(四十个字符长)组成,而函数的体(body)则是脚本本身

1
2
3
4
5
EVAL "return 'hello world'" 0 
# 命令将会定义以下的函数
function f_533203lc6b470dc5a0dd9b4bf2030dea6d65de91() {
return 'hello world'
}

使用函数来保存客户端传入的脚本有以下优点:

  • 通过函数的局部性来让 Lua 环境保持清洁,减少了垃圾回收的工作最, 并且避免了使用全局变量
  • 如果某个脚本在 Lua 环境中被定义过至少一次,那么只需要 SHA1 校验和,服务器就可以在不知道脚本本身的情况下,直接通过调用 Lua 函数来执行脚本

EVAL 命令第二步是将客户端传入的脚本保存到服务器的 lua_scripts 字典里,在字典中新添加一个键值对


执行函数

EVAL 命令第三步是执行脚本函数

  • 将 EVAL 命令中传入的键名参数和脚本参数分别保存到 KEYS 数组和 ARGV 数组,将这两个数组作为全局变量传入到 Lua 环境

  • 为 Lua 环境装载超时处理钩子(hook),这个钩子可以在脚本出现超时运行情况时,让客户端通过 SCRIPT KILL 命令停止脚本,或者通过 SHUTDOWN 命令直接关闭服务器

    因为 Redis 是单线程的执行命令,当 Lua 脚本阻塞时需要兜底策略,可以中断执行

  • 执行脚本函数

  • 移除之前装载的超时钩子

  • 将执行脚本函数的结果保存到客户端状态的输出缓冲区里,等待服务器将结果返回给客户端


EVALSHA

EVALSHA 命令的实现原理就是根据脚本的 SHA1 校验和来调用脚本对应的函数,如果函数在 Lua 环境中不存在,找不到 f_ 开头的函数,就会返回 SCRIPT NOT FOUND


管理命令

Redis 中与 Lua 脚本有关的管理命令有四个:

  • SCRIPT FLUSH:用于清除服务器中所有和 Lua 脚本有关的信息,会释放并重建 lua_scripts 字典,关闭现有的 Lua 环境并重新创建一个新的 Lua 环境

  • SCRIPT EXISTS:根据输入的 SHA1 校验和(允许一次传入多个校验和),检查校验和对应的脚本是否存在于服务器中,通过检查 lua_scripts 字典实现

  • SCRIPT LOAD:在 Lua 环境中为脚本创建相对应的函数,然后将脚本保存到 lua_scripts字典里

    1
    2
    redis> SCRIPT LOAD "return 'hi'"
    "2f3lba2bb6d6a0f42ccl59d2e2dad55440778de3"
  • SCRIPT KILL:停止脚本

如果服务器配置了 lua-time-li­mit 选项,那么在每次执行 Lua 脚本之前,都会设置一个超时处理的钩子。钩子会在脚本运行期间会定期检查运行时间是否超过配置时间,如果超时钩子将定期在脚本运行的间隙中,查看是否有 SCRIPT KILL 或者 SHUTDOWN 到达:

  • 如果超时运行的脚本没有执行过写入操作,客户端可以通过 SCRIPT KILL 来停止这个脚本
  • 如果执行过写入操作,客户端只能用 SHUTDOWN nosave 命令来停止服务器,防止不合法的数据被写入数据库中

脚本复制

命令复制

当服务器运行在复制模式时,具有写性质的脚本命令也会被复制到从服务器,包括 EVAL、EVALSHA、SCRIPT FLUSH,以及 SCRIPT LOAD 命令

Redis 复制 EVAL、SCRIPT FLUSH、SCRIPT LOAD 三个命令的方法和复制普通 Redis 命令的方法一样,当主服务器执行完以上三个命令的其中一个时,会直接将被执行的命令传播(propagate)给所有从服务器,在从服务器中产生相同的效果


EVALSHA

EVALSHA 命令的复制操作相对复杂,因为多个从服务器之间载入 Lua 脚本的清况各有不同,一个在主服务器被成功执行的 EVALSHA 命令,在从服务器执行时可能会出现脚本未找到(not found)错误

Redis 要求主服务器在传播 EVALSHA 命令时,必须确保 EVALSHA 命令要执行的脚本已经被所有从服务器载入过,如果不能确保主服务器会将 EVALSHA 命令转换成一个等价的 EVAL 命令,然后通过传播 EVAL 命令来代替 EVALSHA 命令

主服务器使用服务器状态的 repl_scriptcache_dict 字典记录已经将哪些脚本传播给了所有从服务器,当一个校验和出现在字典时,说明校验和对应的 Lua 脚本已经传播给了所有从服务器,主服务器可以直接传播 EVALSHA 命令

1
2
3
4
struct redisServer {
// 键是一个个 Lua 脚本的 SHA1 校验和,值则全部都是 NULL
dict *repl_scriptcache_dict;
}

注意:每当主服务器添加一个新的从服务器时,都会清空 repl_scriptcache_dict 字典,因为字典里面记录的脚本已经不再被所有从服务器载入过,所以服务器以清空字典的方式,强制重新向所有从服务器传播脚本

通过使用 EVALSHA 命令指定的 SHA1 校验和,以及 lua_scripts 字典保存的 Lua 脚本,可以将一个 EVALSHA 命令转化为 EVAL 命令

1
2
3
EVALSHA "533203lc6b470dc5a0dd9b4bf2030dea6d65de91" 0 
# -> 转换
EVAL "return'hello world'" 0

脚本内容 "return'hello world'" 来源于 lua_scripts 字典 533203lc6b470dc5a0dd9b4bf2030dea6d65de91 键的值


分布式锁

基本操作

在分布式场景下,锁变量需要由一个共享存储系统来维护,多个客户端才可以通过访问共享存储系统来访问锁变量,加锁和释放锁的操作就变成了读取、判断和设置共享存储系统中的锁变量值多步操作

Redis 分布式锁的基本使用,悲观锁

  • 使用 SETNX 设置一个公共锁

    1
    SETNX lock-key value	# value任意数,返回为1设置成功,返回为0设置失败

    NX:只在键不存在时,才对键进行设置操作,SET key value NX 效果等同于 SETNX key value

    XX :只在键已经存在时,才对键进行设置操作

    EX:设置键 key 的过期时间,单位时秒

    PX:设置键 key 的过期时间,单位时毫秒

    说明:由于 SET 命令加上选项已经可以完全取代 SETNX、SETEX、PSETEX 的功能,Redis 不推荐使用这几个命令

  • 操作完毕通过 DEL 操作释放锁

    1
    DEL lock-key 
  • 使用 EXPIRE 为锁 key 添加存活(持有)时间,过期自动删除(放弃)锁,防止线程出现异常,无法释放锁

    1
    2
    EXPIRE lock-key second 
    PEXPIRE lock-key milliseconds

    通过 EXPIRE 设置过期时间缺乏原子性,如果在 SETNX 和 EXPIRE 之间出现异常,锁也无法释放

  • 在 SET 时指定过期时间,保证原子性

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
      SET key value NX [EX seconds | PX milliseconds]





    ****



    ### 防误删

    场景描述:线程 A 正在执行,但是业务阻塞,在锁的过期时间内未执行完成,过期删除后线程 B 重新获取到锁,此时线程 A 执行完成,删除锁,导致线程 B 的锁被线程 A 误删

    SETNX 获取锁时,设置一个指定的唯一值(UUID),释放前获取这个值,判断是否自己的锁,防止出现线程之间误删了其他线程的锁

    ```java
    // 加锁, unique_value作为客户端唯一性的标识,
    // PX 10000 则表示 lock_key 会在 10s 后过期,以免客户端在这期间发生异常而无法释放锁
    SET lock_key unique_value NX PX 10000

Lua 脚本(unlock.script)实现的释放锁操作的伪代码:key 类型参数会放入 KEYS 数组,其它参数会放入 ARGV 数组,在脚本中通过 KEYS 和 ARGV 传递参数,保证判断标识和释放锁这两个操作的原子性

1
EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 lock_key unique_value # 1 代表需要一个参数
1
2
3
4
5
6
7
// 释放锁,KEYS[1] 就是锁的 key,ARGV[1] 就是标识值,避免误释放
// 获取标识值,判断是否与当前线程标示一致
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end

优化锁

不可重入

不可重入:同一个线程无法多次获取同一把锁

使用 hash 键,filed 是加锁的线程标识, value 是锁重入次数

1
2
3
4
|    key    |       value       |
| | filed | value |
|-------------------------------|
| lock_key | thread1 | 1 |

锁重入:

  • 加锁时判断锁的 filed 属性是否是当前线程,如果是将 value 加 1
  • 解锁时判断锁的 filed 属性是否是当前线程,首先将 value 减一,如果 value 为 0 直接释放锁

使用 Lua 脚本保证多条命令的原子性


不可重试

不可重试:获取锁只尝试一次就返回 false,没有重试机制

  • 利用 Lua 脚本尝试获取锁,获取失败获取锁的剩余超时时间 ttl,或者通过参数传入线程抢锁允许等待的时间
  • 利用订阅功能订阅锁释放的信息,然后线程挂起等待 ttl 时间
  • 利用 Lua 脚本在释放锁时,发布一条锁释放的消息

超时释放

超时释放:锁超时释放可以避免死锁,但如果是业务执行耗时较长,需要进行锁续时,防止业务未执行完提前释放锁

看门狗 Watch Dog 机制:

  • 获取锁成功后,提交周期任务,每隔一段时间(Redisson 中默认为过期时间 / 3),重置一次超时时间
  • 如果服务宕机,Watch Dog 机制线程就停止,就不会再延长 key 的过期时间
  • 释放锁后,终止周期任务

主从一致

主从一致性:集群模式下,主从同步存在延迟,当加锁后主服务器宕机时,从服务器还没同步主服务器中的锁数据,此时从服务器升级为主服务器,其他线程又可以获取到锁

将服务器升级为多主多从,:

  • 获取锁需要从所有主服务器 SET 成功才算获取成功
  • 某个 master 宕机,slave 还没有同步锁数据就升级为 master,其他线程尝试加锁会加锁失败,因为其他 master 上已经存在该锁

主从复制

基本操作

主从介绍

主从复制:一个服务器去复制另一个服务器,被复制的服务器为主服务器 master,复制的服务器为从服务器 slave

  • master 用来写数据,执行写操作时,将出现变化的数据自动同步到 slave,很少会进行读取操作
  • slave 用来读数据,禁止在 slave 服务器上进行读操作

进行复制中的主从服务器双方的数据库将保存相同的数据,将这种现象称作数据库状态一致

主从复制的特点:

  • 薪火相传:一个 slave 可以是下一个 slave 的 master,slave 同样可以接收其他 slave 的连接和同步请求,那么该 slave 作为了链条中下一个的 master,可以有效减轻 master 的写压力,去中心化降低风险

    注意:主机挂了,从机还是从机,无法写数据了

  • 反客为主:当一个 master 宕机后,后面的 slave 可以立刻升为 master,其后面的 slave 不做任何修改

主从复制的作用:

  • 读写分离:master 写、slave 读,提高服务器的读写负载能力
  • 负载均衡:基于主从结构,配合读写分离,由 slave 分担 master 负载,并根据需求的变化,改变 slave 的数量,通过多个从节点分担数据读取负载,大大提高 Redis 服务器并发量与数据吞吐量
  • 故障恢复:当 master 出现问题时,由 slave 提供服务,实现快速的故障恢复
  • 数据冗余:实现数据热备份,是持久化之外的一种数据冗余方式
  • 高可用基石:基于主从复制,构建哨兵模式与集群,实现 Redis 的高可用方案

三高架构:

  • 高并发:应用提供某一业务要能支持很多客户端同时访问的能力,称为并发

  • 高性能:性能最直观的感受就是速度快,时间短

  • 高可用:

    • 可用性:应用服务在全年宕机的时间加在一起就是全年应用服务不可用的时间
    • 业界可用性目标 5 个 9,即 99.999%,即服务器年宕机时长低于 315 秒,约 5.25 分钟

操作指令

系统状态指令:

1
INFO replication

master 和 slave 互连:

  • 方式一:客户端发送命令,设置 slaveof 选项,产生主从结构

    1
    slaveof masterip masterport
  • 方式二:服务器带参启动

    1
    redis-server --slaveof masterip masterport
  • 方式三:服务器配置(主流方式)

    1
    slaveof masterip masterport

主从断开连接:

  • slave 断开连接后,不会删除已有数据,只是不再接受 master 发送的数据,可以作为从服务器升级为主服务器的指令

    1
    slaveof no one	

授权访问:master 有服务端和客户端,slave 也有服务端和客户端,不仅服务端之间可以发命令,客户端也可以

  • master 客户端发送命令设置密码:

    1
    requirepass password

    master 配置文件设置密码:

    1
    2
    config set requirepass password
    config get requirepass
  • slave 客户端发送命令设置密码:

    1
    auth password

    slave 配置文件设置密码:

    1
    masterauth password

    slave 启动服务器设置密码:

    1
    redis-server –a password

复制流程

旧版复制

Redis 的复制功能分为同步(sync)和命令传播(command propagate)两个操作,主从库间的复制是异步进行的

同步操作用于将从服务器的数据库状态更新至主服务器当前所处的数据库状态,该过程又叫全量复制:

  • 从服务器向主服务器发送 SYNC 命令来进行同步
  • 收到 SYNC 的主服务器执行 BGSAVE 命令,在后台生成一个 RDB 文件,并使用一个缓冲区记录从现在开始执行的所有写命令
  • 当 BGSAVE 命令执行完毕时,主服务器会将 RDB 文件发送给从服务器
  • 从服务接收并载入 RDB 文件(从服务器会清空原有数据
  • 缓冲区记录了 RDB 文件所在状态后的所有写命令,主服务器将在缓冲区的所有命令发送给从服务器,从服务器执行这些写命令
  • 至此从服务器的数据库状态和主服务器一致

命令传播用于在主服务器的数据库状态被修改,导致主从数据库状态出现不一致时, 让主从服务器的数据库重新回到一致状态

  • 主服务器会将自己执行的写命令,也即是造成主从服务器不一致的那条写命令,发送给从服务器
  • 从服务器接受命令并执行,主从服务器将再次回到一致状态

功能缺陷

SYNC 本身就是一个非常消耗资源的操作,每次执行 SYNC 命令,都需要执行以下动作:

  • 生成 RDB 文件,耗费主服务器大量 CPU 、内存和磁盘 I/O 资源
  • RDB 文件发送给从服务器,耗费主从服务器大量的网络资源(带宽和流量),并对主服务器响应命令请求的时间产生影响
  • 从服务器载入 RDB 文件,期间会因为阻塞而没办法处理命令请求

SYNC 命令下的从服务器对主服务器的复制分为两种情况:

  • 初次复制:从服务器没有复制过任何主服务器,或者从服务器当前要复制的主服务器和上一次复制的主服务器不同
  • 断线后重复制:处于命令传播阶段的主从服务器因为网络原因而中断了复制,自动重连后并继续复制主服务器

旧版复制在断线后重复制时,也会创建 RDB 文件进行全量复制,但是从服务器只需要断线时间内的这部分数据,所以旧版复制的实现方式非常浪费资源


新版复制

Redis 从 2.8 版本开始,使用 PSYNC 命令代替 SYNC 命令来执行复制时的同步操作(命令传播阶段相同),解决了旧版复制在处理断线重复制情况的低效问题

PSYNC 命令具有完整重同步(full resynchronization)和部分重同步(partial resynchronization)两种模式:

  • 完整重同步:处理初次复制情况,执行步骤和 SYNC命令基本一样
  • 部分重同步:处理断线后重复制情况,主服务器可以将主从连接断开期间执行的写命令发送给从服务器,从服务器只要接收并执行这些写命令,就可以将数据库更新至主服务器当前所处的状态,该过程又叫部分复制

部分同步

部分重同步功能由以下三个部分构成:

  • 主服务器的复制偏移量(replication offset)和从服务器的复制偏移量
  • 主服务器的复制积压缓冲区(replication backlog)
  • 服务器的运行 ID (run ID)

偏移量

主服务器和从服务器会分别维护一个复制偏移量:

  • 主服务器每次向从服务器传播 N 个字节的数据时,就将自己的复制偏移量的值加上 N

  • 从服务器每次收到主服务器传播来的 N 个字节的数据时,就将自己的复制偏移量的值加上 N

通过对比主从服务器的复制偏移量,可以判断主从服务器是否处于一致状态

  • 主从服务器的偏移量是相同的,说明主从服务器处于一致状态
  • 主从服务器的偏移量是不同的,说明主从服务器处于不一致状态

缓冲区

复制积压缓冲区是由主服务器维护的一个固定长度(fixed-size)先进先出(FIFO)队列,默认大小为 1MB

  • 出队规则跟普通的先进先出队列一样
  • 入队规则是当入队元素的数量大于队列长度时,最先入队的元素会被弹出,然后新元素才会被放入队列

当主服务器进行命令传播时,不仅会将写命令发送给所有从服务器,还会将写命令入队到复制积压缓冲区,缓冲区会保存着一部分最近传播的写命令,并且缓冲区会为队列中的每个字节记录相应的复制偏移量

从服务器会通过 PSYNC 命令将自己的复制偏移量 offset 发送给主服务器,主服务器会根据这个复制偏移量来决定对从服务器执行何种同步操作:

  • offset 之后的数据(即 offset+1)仍然存在于复制积压缓冲区里,那么主服务器将对从服务器执行部分重同步操作
  • offset 之后的数据已经不在复制积压缓冲区,说明部分数据已经丢失,那么主服务器将对从服务器执行完整重同步操作

复制缓冲区大小设定不合理,会导致数据溢出。比如主服务器需要执行大量写命令,又或者主从服务器断线后重连接所需的时间较长,导致缓冲区中的数据已经丢失,则必须进行完整重同步

1
repl-backlog-size ?mb

建议设置如下,这样可以保证绝大部分断线情况都能用部分重同步来处理:

  • 从服务器断线后重新连接上主服务器所需的平均时间 second
  • 获取 master 平均每秒产生写命令数据总量 write_size_per_second
  • 最优复制缓冲区空间 = 2 * second * write_size_per_second

运行ID

服务器运行 ID(run ID):是每一台服务器每次运行的身份识别码,在服务器启动时自动生成,由 40 位随机的十六进制字符组成,一台服务器多次运行可以生成多个运行 ID

作用:服务器间进行传输识别身份,如果想两次操作均对同一台服务器进行,每次必须操作携带对应的运行 ID,用于对方识别

从服务器对主服务器进行初次复制时,主服务器将自己的运行 ID 传送给从服务器,然后从服务器会将该运行 ID 保存。当从服务器断线并重新连上一个主服务器时,会向当前连接的主服务器发送之前保存的运行 ID:

  • 如果运行 ID 和当前连接的主服务器的运行 ID 相同,说明从服务器断线之前复制的就是当前连接的这个主服务器,执行部分重同步
  • 如果不同,需要执行完整重同步操作

PSYNC

PSYNC 命令的调用方法有两种

  • 如果从服务器之前没有复制过任何主服务器,或者执行了 SLAVEOF no one,开始一次新的复制时将向主服务器发送 PSYNC ? -1 命令,主动请求主服务器进行完整重同步
  • 如果从服务器已经复制过某个主服务器,那么从服务器在开始一次新的复制时将向主服务器发送 PSYNC <runid> <offset> 命令,runid 是上一次复制的主服务器的运行 ID,offset 是复制的偏移量

接收到 PSYNC 命令的主服务器会向从服务器返回以下三种回复的其中一种:

  • 执行完整重同步操作:返回 +FULLRESYNC <runid> <offset>,runid 是主服务器的运行 ID,offset 是主服务器的复制偏移量
  • 执行部分重同步操作:返回 +CONTINUE,从服务器收到该回复说明只需要等待主服务器发送缺失的部分数据即可
  • 主服务器的版本低于 Redis2.8:返回 -ERR,版本过低识别不了 PSYNC,从服务器将向主服务器发送 SYNC 命令

复制实现

实现流程

通过向从服务器发送 SLAVEOF 命令,可以让从服务器去复制一个主服务器

  • 设置主服务器的地址和端口:将 SLAVEOF 命令指定的 ip 和 port 保存到服务器状态 redisServer

    1
    2
    3
    4
    5
    6
    struct redisServer {
    // 主服务器的地址
    char *masterhost;
    //主服务器的端口
    int masterport;
    };

    SLAVEOF 命令是一个异步命令,在完成属性的设置后服务器直接返回 OK,而实际的复制工作将在 OK 返回之后才真正开始执行

  • 建立套接字连接:

    • 从服务器 connect 主服务器建立套接字连接,成功后从服务器将为这个套接字关联一个用于复制工作的文件事件处理器,负责执行后续的复制工作,如接收 RDB 文件、接收主服务器传播来的写命令等
    • 主服务器在接受 accept 从务器的套接字连接后,将为该套接字创建相应的客户端状态,将从服务器看作一个客户端,从服务器将同时具有 server 和 client(可以发命令)两个身份
  • 发送 PING 命令:从服务器向主服务器发送一个 PING 命令,检查主从之间的通信是否正常、主服务器处理命令的能力是否正常

    • 返回错误,表示主服务器无法处理从服务器的命令请求(忙碌),从服务器断开并重新创建连向主服务器的套接字
    • 返回命令回复,但从服务器不能在规定的时间内读取出命令回复的内容,表示主从之间的网络状态不佳,需要断开重连
    • 读取到 PONG,表示一切状态正常,可以执行复制
  • 身份验证:如果从服务器设置了 masterauth 选项就进行身份验证,将向主服务器发送一条 AUTH 命令,命令参数为从服务器 masterauth 选项的值,如果主从设置的密码不相同,那么主将返回一个 invalid password 错误

  • 发送端口信息:身份验证后

    • 从服务器执行命令 REPLCONF listening-port <port­number>, 向主服务器发送从服务器的监听端口号
    • 主服务器在接收到这个命令后,会将端口号记录在对应的客户端状态 redisClient.slave_listening_port 属性中:
  • 同步:从服务器将向主服务器发送 PSYNC 命令,在同步操作执行之后,主从服务器双方都是对方的客户端,可以相互发送命令

    • 完整重同步:主服务器需要成为从服务器的客户端,才能将保存在缓冲区里面的写命令发送给从服务器执行

    • 部分重同步:主服务器需要成为从服务器的客户端,才能向从服务器发送保存在复制积压缓冲区里面的写命令

  • 命令传播:主服务器将写命令发送给从服务器,保持数据库的状态一致


复制图示


心跳检测

心跳机制

心跳机制:进入命令传播阶段,从服务器默认会以每秒一次的频率,向主服务器发送命令:REPLCONF ACK <replication_offset>,re_offset 是从服务器当前的复制偏移量

心跳的作用:

  • 检测主从服务器的网络连接状态
  • 辅助实现 min-slaves 选项
  • 检测命令丢失

网络状态

如果主服务器超过一秒钟没有收到从服务器发来的 REPLCONF ACK 命令,主服务就认为主从服务器之间的连接出现问题

向主服务器发送 INFO replication 命令,lag 一栏表示从服务器最后一次向主服务器发送 ACK 命令距离现在多少秒:

1
2
3
4
5
6
127.0.0.1:6379> INFO replication 
# Replication
role:master
connected_slaves:2
slave0: ip=127.0.0.1,port=11111,state=online,offset=123,lag=0 # 刚刚发送过 REPLCONF ACK
slavel: ip=127.0.0.1,port=22222,state=online,offset=456,lag=3 # 3秒之前发送过REPLCONF ACK

在一般情况下,lag 的值应该在 0 或者 1 秒之间跳动,如果超过 1 秒说明主从服务器之间的连接出现了故障


配置选项

Redis 的 min-slaves-to-write 和 min-slaves-max-lag 两个选项可以防止主服务器在不安全的情况下执行写命令

比如向主服务器设置:

1
2
min-slaves-to-write 5
min-slaves-max-lag 10

那么在从服务器的数少于 5 个,或者 5 个从服务器的延迟(lag)值都大于或等于10 秒时,主服务器将拒绝执行写命令


命令丢失

检测命令丢失:由于网络或者其他原因,主服务器传播给从服务器的写命令丢失,那么当从服务器向主服务器发送 REPLCONF ACK 命令时,主服务器会检查从服务器的复制偏移量是否小于自己的,然后在复制积压缓冲区里找到从服务器缺少的数据,并将这些数据重新发送给从服务器

说明:REPLCONF ACK 命令和复制积压缓冲区都是 Redis 2.8 版本新增的,在 Redis 2.8 版本以前,即使命令在传播过程中丢失,主从服务器都不会注意到,也不会向从服务器补发丢失的数据,所以为了保证主从复制的数据一致性,最好使用 2.8 或以上版本的 Redis


常见问题

重启恢复

系统不断运行,master 的数据量会越来越大,一旦 master 重启,runid 将发生变化,会导致全部 slave 的全量复制操作

解决方法:本机保存上次 runid,重启后恢复该值,使所有 slave 认为还是之前的 master

优化方案:

  • master 内部创建 master_replid 变量,使用 runid 相同的策略生成,并发送给所有 slave

  • 在 master 关闭时执行命令 shutdown save,进行 RDB 持久化,将 runid 与 offset 保存到 RDB 文件中

    redis-check-rdb dump.rdb 命令可以查看该信息,保存为 repl-id 和 repl-offset

  • master 重启后加载 RDB 文件,恢复数据,将 RDB 文件中保存的 repl-id 与 repl-offset 加载到内存中,master_repl_id = repl-id,master_repl_offset = repl-offset

  • 通过 info 命令可以查看该信息


网络中断

master 的 CPU 占用过高或 slave 频繁断开连接

  • 出现的原因:

    • slave 每 1 秒发送 REPLCONF ACK 命令到 master
    • 当 slave 接到了慢查询时(keys * ,hgetall等),会大量占用 CPU 性能
    • master 每 1 秒调用复制定时函数 replicationCron(),比对 slave 发现长时间没有进行响应

    最终导致 master 各种资源(输出缓冲区、带宽、连接等)被严重占用

  • 解决方法:通过设置合理的超时时间,确认是否释放 slave

    1
    repl-timeout	# 该参数定义了超时时间的阈值(默认60秒),超过该值,释放slave

slave 与 master 连接断开

  • 出现的原因:

    • master 发送 ping 指令频度较低
    • master 设定超时时间较短
    • ping 指令在网络中存在丢包
  • 解决方法:提高 ping 指令发送的频度

    1
    repl-ping-slave-period	

    超时时间 repl-time 的时间至少是 ping 指令频度的5到10倍,否则 slave 很容易判定超时


一致性

网络信息不同步,数据发送有延迟,导致多个 slave 获取相同数据不同步

解决方案:

  • 优化主从间的网络环境,通常放置在同一个机房部署,如使用阿里云等云服务器时要注意此现象

  • 监控主从节点延迟(通过offset)判断,如果 slave 延迟过大,暂时屏蔽程序对该 slave 的数据访问

    1
    slave-serve-stale-data yes|no

    开启后仅响应 info、slaveof 等少数命令(慎用,除非对数据一致性要求很高)

  • 多个 slave 同时对 master 请求数据同步,master 发送的 RDB 文件增多,会对带宽造成巨大冲击,造成 master 带宽不足,因此数据同步需要根据业务需求,适量错峰


哨兵模式

哨兵概述

Sentinel(哨兵)是 Redis 的高可用性(high availability)解决方案,由一个或多个 Sentinel 实例 instance 组成的 Sentinel 系统可以监视任意多个主服务器,以及这些主服务器的所有从服务器,并在被监视的主服务器下线时进行故障转移

  • 双环图案表示主服务器
  • 单环图案表示三个从服务器

哨兵的作用:

  • 监控:监控 master 和 slave,不断的检查 master 和 slave 是否正常运行,master 存活检测、master 与 slave 运行情况检测

  • 通知:当被监控的服务器出现问题时,向其他哨兵发送通知

  • 自动故障转移:断开 master 与 slave 连接,选取一个 slave 作为 master,将其他 slave 连接新的 master,并告知客户端新的服务器地址


启用哨兵

配置方式

配置三个哨兵 sentinel.conf:一般多个哨兵配置相同、端口不同,特殊需求可以配置不同的属性

1
2
3
4
5
6
7
port 26401
dir "/redis/data"
sentinel monitor mymaster 127.0.0.1 6401 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 20000
sentinel parallel-sync mymaster 1
sentinel deny-scripts-reconfig yes

配置说明:

  • 设置哨兵监听的主服务器信息,判断主观下线所需要的票数

    1
    sentinel monitor <master-name> <master_ip> <master_port> <quorum>
  • 指定哨兵在监控 Redis 服务时,设置判定服务器宕机的时长,该设置控制是否进行主从切换

    1
    sentinel down-after-milliseconds <master-name> <million_seconds>
  • 出现故障后,故障切换的最大超时时间,超过该值,认定切换失败,默认 3 分钟

    1
    sentinel failover-timeout <master_name> <million_seconds>
  • 故障转移时,同时进行主从同步的 slave 数量,数值越大,要求网络资源越高

    1
    sentinel parallel-syncs <master_name> <sync_slave_number>

启动哨兵:服务端命令(Linux 命令)

1
redis-sentinel filename

初始化

Sentinel 本质上只是一个运行在特殊模式下的 Redis 服务器,当一个 Sentinel 启动时,首先初始化 Redis 服务器,但是初始化过程和普通 Redis 服务器的初始化过程并不完全相同,哨兵不提供数据相关服务,所以不会载入 RDB、AOF 文件

整体流程:

  • 初始化服务器

  • 将普通 Redis 服务器使用的代码替换成 Sentinel 专用代码

  • 初始化 Sentinel 状态

  • 根据给定的配置文件,初始化 Sentinel 的监视主服务器列表

  • 创建连向主服务器的网络连接


代码替换

将一部分普通 Redis服务器使用的代码替换成 Sentinel 专用代码

Redis 服务器端口:

1
2
# define REDIS_SERVERPORT 6379 		// 普通服务器端口
# define REDIS_SENTINEL_PORT 26379 // 哨兵端口

服务器的命令表:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 普通 Redis 服务器
struct redisCommand redisCommandTable[] = {
{"get", getCommand, 2, "r", 0, NULL, 1, 1, 1, 0, 0},
{"set", setCommand, -3, "wm", 0, noPreloadGetKeys, 1, 1, 1, 0, 0},
//....
}
// 哨兵
struct redisCommand sentinelcmds[] = {
{"ping", pingCommand, 1, "", 0, NULL, 0, 0, 0, 0, 0},
{"sentinel", sentinelCommand, -2,"",0,NULL,0,0,0,0,0},
{"subscribe",...}, {"unsubscribe",...O}, {"psubscribe",...}, {"punsubscribe",...},
{"info",...}
};

上述表是哨兵模式下客户端可以执行的命令,所以对于 GET、SET 等命令,服务器根本就没有载入


哨兵状态

服务器会初始化一个 sentinelState 结构,又叫 Sentinel 状态,结构保存了服务器中所有和 Sentinel 功能有关的状态(服务器的一般状态仍然由 redisServer 结构保存)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct sentinelState {
// 当前纪元,用于实现故障转移
uint64_t current_epoch;

// 保存了所有被这个sentinel监视的主服务器
dict *masters;

// 是否进入了 TILT 模式
int tilt;
// 进入 TILT 模式的时间
mstime_t tilt_start_time;

// 最后一次执行时间处理的事件
mstime_t previous_time;

// 目前正在执行的脚本数量
int running_scripts;
// 一个FIFO队列,包含了所有需要执行的用户脚本
list *scripts_queue;

} sentinel;

监控列表

Sentinel 状态的初始化将 masters 字典的初始化,根据被载入的 Sentinel 配置文件 conf 来进行属性赋值

Sentinel 状态中的 masters 字典记录了所有被 Sentinel 监视的主服务器的相关信息,字典的键是被监视主服务器的名字,值是主服务器对应的实例结构

实例结构是一个 sentinelRedisinstance 数据类型,代表被 Sentinel 监视的实例,这个实例可以是主、从服务器,或者其他 Sentinel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
typedef struct sentinelRedisinstance {
// 标识值,记录了实例的类型,以及该实例的当前状态
int flags;

// 实例的名字,主服务器的名字由用户在配置文件中设置,
// 从服务器和哨兵的名字由 Sentinel 自动设置,格式为 ip:port,例如 127.0.0.1:6379
char *name;

// 实例运行的 ID
char *runid;

// 配置纪元,用于实现故障转移
uint64_t config_epoch;

// 实例地址
sentinelAddr *addr;

// 如果当前实例时主服务器,该字段保存从服务器信息,键是名字格式为 ip:port,值是实例结构
dict *slaves;

// 所有监视当前服务器的 Sentinel 实例,键是名字格式为 ip:port,值是实例结构
dict *sentinels;

// sentinel down-after-milliseconds 的值,表示实例无响应多少毫秒后会被判断为主观下线(subjectively down)
mstime_t down_after_period;

// sentinel monitor 选项中的quorum参数,判断这个实例为客观下线(objectively down)所需的支持投票数量
int quorum;

// sentinel parallel-syncs 的值,在执行故障转移操作时,可以同时对新的主服务器进行同步的从服务器数量
int parallel-syncs;

// sentinel failover-timeout的值,刷新故障迁移状态的最大时限
mstime_t failover_timeout;
}

addr 属性是一个指向 sentinelAddr 的指针:

1
2
3
4
typedef struct sentinelAddr {
char *ip;
int port;
}

网络连接

初始化 Sentinel 的最后一步是创建连向被监视主服务器的网络连接,Sentinel 将成为主服务器的客户端,可以向主服务器发送命令,并从命令回复中获取相关的信息

每个被 Sentinel 监视的主服务器,Sentinel 会创建两个连向主服务器的异步网络连接

  • 命令连接:用于向主服务器发送命令,并接收命令回复
  • 订阅连接:用于订阅主服务器的 _sentinel_:hello 频道

建立两个连接的原因:

  • 在 Redis 目前的发布与订阅功能中,被发送的信息都不会保存在 Redis 服务器里, 如果在信息发送时接收信息的客户端离线或断线,那么这个客户端就会丢失这条信息,为了不丢失 hello 频道的任何信息,Sentinel 必须用一个订阅连接来接收该频道的信息

  • Sentinel 还必须向主服务器发送命令,以此来与主服务器进行通信,所以 Sentinel 还必须向主服务器创建命令连接

说明:断线的意思就是网络连接断开


信息交互

获取信息

主服务器

Sentinel 默认会以每十秒一次的频率,通过命令连接向被监视的主服务器发送 INFO 命令,来获取主服务器的当前信息

  • 一部分是主服务器本身的信息,包括 runid 域记录的服务器运行 ID,以及 role 域记录的服务器角色
  • 另一部分是服务器属下所有从服务器的信息,每个从服务器都由一个 slave 字符串开头的行记录,根据这些 IP 地址和端口号,Sentinel 无须用户提供从服务器的地址信息,就可以自动发现从服务器
1
2
3
4
5
6
7
8
9
# Server 
run_id:76llc59dc3a29aa6fa0609f84lbb6al019008a9c
...
# Replication
role:master
...
slave0: ip=l27.0.0.1, port=11111, state=online, offset=22, lag=0
slave1: ip=l27.0.0.1, port=22222, state=online, offset=22, lag=0
...

根据 run_id 和 role 记录的信息 Sentinel 将对主服务器的实例结构进行更新,比如主服务器重启之后,运行 ID 就会和实例结构之前保存的运行 ID 不同,哨兵检测到这一情况之后就会对实例结构的运行 ID 进行更新

对于主服务器返回的从服务器信息,用实例结构的 slaves 字典记录了从服务器的信息:

  • 如果从服务器对应的实例结构已经存在,那么 Sentinel 对从服务器的实例结构进行更新
  • 如果不存在,为这个从服务器新创建一个实例结构加入字典,字典键为 ip:port

从服务器

当 Sentinel 发现主服务器有新的从服务器出现时,会为这个新的从服务器创建相应的实例结构,还会创建到从服务器的命令连接和订阅连接,所以 Sentinel 对所有的从服务器之间都可以进行命令操作

Sentinel 默认会以每十秒一次的频率,向从服务器发送 INFO 命令:

1
2
3
4
5
6
7
8
9
10
11
12
# Server 
run_id:76llc59dc3a29aa6fa0609f84lbb6al019008a9c #从服务器的运行 id
...
# Replication
role:slave # 从服务器角色
...
master_host:127.0.0.1 # 主服务器的 ip
master_port:6379 # 主服务器的 port
master_link_status:up # 主从服务器的连接状态
slave_repl_offset:11111 # 从服务器的复制偏移蜇
slave_priority:100 # 从服务器的优先级
...
  • 优先级属性在故障转移时会用到

根据这些信息,Sentinel 会对从服务器的实例结构进行更新


发送信息

Sentinel 在默认情况下,会以每两秒一次的频率,通过命令连接向所有被监视的主服务器和从服务器发送以下格式的命令:

1
PUBLISH _sentinel_:hello "<s_ip>, <s_port>, <s_runid>, <s_epoch>, <m_name>, <m_ip>, <m_port>, <m_epoch>

这条命令向服务器的 _sentinel_:hello 频道发送了一条信息,信息的内容由多个参数组成:

  • 以 s_ 开头的参数记录的是 Sentinel 本身的信息
  • 以 m_ 开头的参数记录的则是主服务器的信息

说明:通过命令连接发送的频道信息


接受信息

订阅频道

Sentinel 与一个主或从服务器建立起订阅连接之后,就会通过订阅连接向服务器发送订阅命令,频道的订阅会一直持续到 Sentinel 与服务器的连接断开为止

1
SUBSCRIBE _sentinel_:hello

订阅成功后,Sentinel 就可以通过订阅连接从服务器的 _sentinel_:hello 频道接收信息,对消息分析:

  • 如果信息中记录的 Sentinel 运行 ID 与自己的相同,不做进一步处理
  • 如果不同,将根据信息中的各个参数,对相应主服务器的实例结构进行更新

对于监视同一个服务器的多个 Sentinel 来说,一个 Sentinel 发送的信息会被其他 Sentinel 接收到,这些信息会被用于更新其他 Sentinel 对发送信息 Sentinel 的认知,也会被用于更新其他 Sentinel 对被监视的服务器的认知

哨兵实例之间可以相互发现,要归功于 Redis 提供发布订阅机制


更新字典

Sentinel 为主服务器创建的实例结构的 sentinels 字典保存所有同样监视这个主服务器的 Sentinel 信息(包括 Sentinel 自己),字典的键是 Sentinel 的名字,格式为 ip:port,值是键所对应 Sentinel 的实例结构

当 Sentinel 接收到其他 Sentinel 发来的信息时(发送信息的为源 Sentinel,接收信息的为目标 Sentinel),目标 Sentinel 会分析提取参数,在自己的 Sentinel 状态 sentinelState.masters 中查找相应的主服务器实例结构,检查主服务器实例结构的 sentinels 字典中,源 Sentinel 的实例结构是否存在

  • 如果源 Sentinel 的实例结构存在,那么对源 Sentinel 的实例结构进行更新
  • 如果源 Sentinel 的实例结构不存在,说明源 Sentinel 是刚开始监视主服务器,目标 Sentinel 会为源 Sentinel 创建一个新的实例结构,并将这个结构添加到 sentinels 字典里面

因为 Sentinel 可以接收到的频道信息来获知其他 Sentinel 的存在,并通过发送频道信息来让其他 Sentinel 知道自己的存在,所以用户在使用 Sentinel 时并不需要提供各个 Sentinel 的地址信息,监视同一个主服务器的多个 Sentinel 可以自动发现对方


命令连接

Sentinel 通过频道信息发现新的 Sentinel,除了创建实例结构,还会创建一个连向新 Sentinel 的命令连接,而新 Sentinel 也同样会创建连向这个 Sentinel 的命令连接,最终监视同一主服务器的多个 Sentinel 将形成相互连接的网络

作用:通过命令连接相连的各个 Sentinel 可以向其他 Sentinel 发送命令请求来进行信息交换

Sentinel 之间不会创建订阅连接:

  • Sentinel 需要通过接收主服务器或者从服务器发来的频道信息来发现未知的新 Sentinel,所以才创建订阅连接
  • 相互已知的 Sentinel 只要使用命令连接来进行通信就足够了

下线检测

主观下线

Sentinel 在默认情况下会以每秒一次的频率向所有与它创建了命令连接的实例(包括主从服务器、其他 Sentinel)发送 PING 命令,通过实例返回的 PING 命令回复来判断实例是否在线

  • 有效回复:实例返回 +PONG、-LOADING、-MASTERDOWN 三种回复的其中一种
  • 无效回复:实例返回除上述三种以外的任何数据

Sentinel 配置文件中 down-after-milliseconds 选项指定了判断实例进入主观下线所需的时长,如果主服务器在该时间内一直向 Sentinel 返回无效回复,Sentinel 就会在该服务器对应实例结构的 flags 属性打开 SRI_S_DOWN 标识,表示该主服务器进入主观下线状态

配置的 down-after-milliseconds 值不仅适用于主服务器,还会被用于当前 Sentinel 判断主服务器属下的所有从服务器,以及所有同样监视这个主服务器的其他 Sentinel 的主观下线状态

注意:对于监视同一个主服务器的多个 Sentinel 来说,设置的 down-after-milliseconds 选项的值可能不同,所以当一个 Sentinel 将主服务器判断为主观下线时,其他 Sentinel 可能仍然会认为主服务器处于在线状态


客观下线

当 Sentinel 将一个主服务器判断为主观下线之后,会向同样监视这一主服务器的其他 Sentinel 进行询问

Sentinel 使用命令询问其他 Sentinel 是否同意主服务器已下线:

1
SENTINEL is-master-down-by-addr <ip> <port> <current_epoch> <runid>
  • ip:被 Sentinel 判断为主观下线的主服务器的 IP 地址
  • port:被 Sentinel 判断为主观下线的主服务器的端口号
  • current_epoch:Sentinel 当前的配置纪元,用于选举领头 Sentinel
  • runid:取值为 * 符号代表命令仅仅用于检测主服务器的客观下线状态;取值为 Sentinel 的运行 ID 则用于选举领头 Sentinel

目标 Sentinel 接收到源 Sentinel 的命令时,会根据参数的 lP 和端口号,检查主服务器是否已下线,然后返回一条包含三个参数的 Multi Bulk 回复:

  • down_state:返回目标 Sentinel 对服务器的检查结果,1 代表主服务器已下线,0 代表未下线
  • leader_runid:取值为 * 符号代表命令仅用于检测服务器的下线状态;而局部领头 Sentinel 的运行 ID 则用于选举领头 Sentinel
  • leader_epoch:目标 Sentinel 的局部领头 Sentinel 的配置纪元

源 Sentinel 将统计其他 Sentinel 同意主服务器已下线的数量,当这一数量达到配置指定的判断客观下线所需的数量(quorum)时,Sentinel 会将主服务器对应实例结构 flags 属性的 SRI_O_DOWN 标识打开,代表客观下线,并对主服务器执行故障转移操作

注意:不同 Sentinel 判断客观下线的条件可能不同,因为载入的配置文件中的属性(quorum)可能不同


领头选举

主服务器被判断为客观下线时,监视这个主服务器的各个 Sentinel 会进行协商,选举出一个领头 Sentinel 对下线服务器执行故障转移

Redis 选举领头 Sentinel 的规则:

  • 所有在线的 Sentinel 都有被选为领头 Sentinel 的资格

  • 每个发现主服务器进入客观下线的 Sentinel 都会要求其他 Sentinel 将自己设置为局部领头 Sentinel

  • 在一个配置纪元里,所有 Sentinel 都只有一次将某个 Sentinel 设置为局部领头 Sentinel 的机会,并且局部领头一旦设置,在这个配置纪元里就不能再更改

  • Sentinel 设置局部领头 Sentinel 的规则是先到先得,最先向目标 Sentinel 发送设置要求的源 Sentinel 将成为目标 Sentinel 的局部领头 Sentinel,之后接收到的所有设置要求都会被目标 Sentinel 拒绝

  • 领头 Sentinel 的产生需要半数以上 Sentinel 的支持,并且每个 Sentinel 只有一票,所以一个配置纪元只会出现一个领头 Sentinel,比如 10 个 Sentinel 的系统中,至少需要 10/2 + 1 = 6

选举过程:

  • 一个 Sentinel 向目标 Sentinel 发送 SENTINEL is-master-down-by-addr 命令,命令中的 runid 参数不是*符号而是源 Sentinel 的运行 ID,表示源 Sentinel 要求目标 Sentinel 将自己设置为它的局部领头 Sentinel
  • 目标 Sentinel 接受命令处理完成后,将返回一条命令回复,回复中的 leader_runid 和 leader_epoch 参数分别记录了目标 Sentinel 的局部领头 Sentinel 的运行 ID 和配置纪元
  • 源 Sentinel 接收目标 Sentinel 命令回复之后,会判断 leader_epoch 是否和自己的相同,相同就继续判断 leader_runid 是否和自己的运行 ID 一致,成立表示目标 Sentinel 将源 Sentinel 设置成了局部领头 Sentinel,即获得一票
  • 如果某个 Sentinel 被半数以上的 Sentinel 设置成了局部领头 Sentinel,那么这个 Sentinel 成为领头 Sentinel
  • 如果在给定时限内,没有一个 Sentinel 被选举为领头 Sentinel,那么各个 Sentinel 将在一段时间后再次选举,直到选出领头
  • 每次进行领头 Sentinel 选举之后,不论选举是否成功,所有 Sentinel 的配置纪元(configuration epoch)都要自增一次

Sentinel 集群至少 3 个节点的原因:

  • 如果 Sentinel 集群只有 2 个 Sentinel 节点,则领头选举需要 2/2 + 1 = 2 票,如果一个节点挂了,那就永远选不出领头
  • Sentinel 集群允许 1 个 Sentinel 节点故障则需要 3 个节点的集群,允许 2 个节点故障则需要 5 个节点集群

故障转移

执行流程

领头 Sentinel 将对已下线的主服务器执行故障转移操作,该操作包含以下三个步骤

  • 从下线主服务器属下的所有从服务器里面,挑选出一个从服务器,执行 SLAVEOF no one,将从服务器升级为主服务器

    在发送 SLAVEOF no one 命令后,领头 Sentinel 会以每秒一次的频率(一般是 10s/次)向被升级的从服务器发送 INFO 命令,观察命令回复中的角色信息,当被升级服务器的 role 从 slave 变为 master 时,说明从服务器已经顺利升级为主服务器

  • 将已下线的主服务器的所有从服务器改为复制新的主服务器,通过向从服务器发送 SLAVEOF 命令实现

  • 将已经下线的主服务器设置为新的主服务器的从服务器,设置是保存在服务器对应的实例结构中,当旧的主服务器重新上线时,Sentinel 就会向它发送 SLAVEOF 命令,成为新的主服务器的从服务器

示例:sever1 是主,sever2、sever3、sever4 是从服务器,sever1 故障后选中 sever2 升级


选择算法

领头 Sentinel 会将已下线主服务器的所有从服务器保存到一个列表里,然后按照以下规则对列表进行过滤,最后挑选出一个状态良好、数据完整的从服务器

  • 删除列表中所有处于下线或者断线状态的从服务器,保证列表中的从服务器都是正常在线的

  • 删除列表中所有最近五秒内没有回复过领头 Sentinel 的 INFO 命令的从服务器,保证列表中的从服务器最近成功进行过通信

  • 删除所有与已下线主服务器连接断开超过 down-after-milliseconds * 10 毫秒的从服务器,保证列表中剩余的从服务器都没有过早地与主服务器断开连接,保存的数据都是比较新的

    down-after-milliseconds 时间用来判断是否主观下线,其余的时间完全可以完成客观下线和领头选举

  • 根据从服务器的优先级,对列表中剩余的从服务器进行排序,并选出其中优先级最高的从服务器

  • 如果有多个具有相同最高优先级的从服务器,领头 Sentinel 将对这些相同优先级的服务器按照复制偏移量进行排序,选出其中偏移量最大的从服务器,也就是保存着最新数据的从服务器

  • 如果还没选出来,就按照运行 ID 对这些从服务器进行排序,并选出其中运行 ID 最小的从服务器


集群模式

集群节点

节点概述

Redis 集群是 Redis 提供的分布式数据库方案,集群通过分片(sharding)来进行数据共享, 并提供复制和故障转移功能,一个 Redis 集群通常由多个节点(node)组成,将各个独立的节点连接起来,构成一个包含多节点的集群

一个节点就是一个运行在集群模式下的 Redis 服务器,Redis 在启动时会根据配置文件中的 cluster-enabled 配置选项是否为 yes 来决定是否开启服务器的集群模式

节点会继续使用所有在单机模式中使用的服务器组件,使用 redisServer 结构来保存服务器的状态,使用 redisClient 结构来保存客户端的状态,也有集群特有的数据结构


数据结构

每个节点都保存着一个集群状态 clusterState 结构,这个结构记录了在当前节点的视角下,集群目前所处的状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typedef struct clusterState {
// 指向当前节点的指针
clusterNode *myself;

// 集群当前的配置纪元,用于实现故障转移
uint64_t currentEpoch;

// 集群当前的状态,是在线还是下线
int state;

// 集群中至少处理着一个槽的节点的数量,为0表示集群目前没有任何节点在处理槽
int size;

// 集群节点名单(包括 myself 节点),字典的键为节点的名字,字典的值为节点对应的clusterNode结构
dict *nodes;
}

每个节点都会使用 clusterNode 结构记录当前状态,并为集群中的所有其他节点(包括主节点和从节点)都创建一个相应的 clusterNode 结构,以此来记录其他节点的状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct clusterNode {
// 创建节点的时间
mstime_t ctime;

// 节点的名字,由 40 个十六进制字符组成
char name[REDIS_CLUSTER_NAMELEN];

// 节点标识,使用各种不同的标识值记录节点的角色(比如主节点或者从节点)以及节点目前所处的状态(比如在线或者下线)
int flags;

// 节点当前的配置纪元,用于实现故障转移
uint64_t configEpoch;

// 节点的IP地址
char ip[REDIS_IP_STR_LEN];

// 节点的端口号
int port;

// 保存连接节点所需的有关信息
clusterLink *link;
}

clusterNode 结构的 link 属性是一个 clusterLink 结构,该结构保存了连接节点所需的有关信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typedef struct clusterLink {
// 连接的创建时间
mstime_t ctime;

// TCP套接字描述符
int fd;

// 输出缓冲区,保存着等待发送给其他节点的消息(message)。
sds sndbuf;

// 输入缓冲区,保存着从其他节点接收到的消息。
sds rcvbuf;

// 与这个连接相关联的节点,如果没有的话就为NULL
struct clusterNode *node;
}
  • redisClient 结构中的套接宇和缓冲区是用于连接客户端的
  • clusterLink 结构中的套接宇和缓冲区则是用于连接节点的

MEET

CLUSTER MEET 命令用来将 ip 和 port 所指定的节点添加到接受命令的节点所在的集群中

1
CLUSTER MEET <ip> <port> 

假设向节点 A 发送 CLUSTER MEET 命令,让节点 A 将另一个节点 B 添加到节点 A 当前所在的集群里,收到命令的节点 A 将与根据 ip 和 port 向节点 B 进行握手(handshake):

  • 节点 A 会为节点 B 创建一个 clusterNode 结构,并将该结构添加到自己的 clusterState.nodes 字典里,然后节点 A 向节点 B 发送 MEET 消息(message)
  • 节点 B 收到 MEET 消息后,节点 B 会为节点 A 创建一个 clusterNode 结构,并将该结构添加到自己的 clusterState.nodes 字典里,之后节点 B 将向节点 A 返回一条 PONG 消息
  • 节点 A 收到 PONG 消息后,代表节点 A 可以知道节点 B 已经成功地接收到了自已发送的 MEET 消息,此时节点 A 将向节点 B 返回一条 PING 消息
  • 节点 B 收到 PING 消息后, 代表节点 B 可以知道节点 A 已经成功地接收到了自己返回的 PONG 消息,握手完成

节点 A 会将节点 B 的信息通过 Gossip 协议传播给集群中的其他节点,让其他节点也与节点 B 进行握手,最终经过一段时间之后,节点 B 会被集群中的所有节点认识


槽指派

基本操作

Redis 集群通过分片的方式来保存数据库中的键值对,集群的整个数据库被分为16384 个槽(slot),数据库中的每个键都属于 16384 个槽中的一个,集群中的每个节点可以处理 0 个或最多 16384 个槽(每个主节点存储的数据并不一样

  • 当数据库中的 16384 个槽都有节点在处理时,集群处于上线状态(ok)
  • 如果数据库中有任何一个槽得到处理,那么集群处于下线状态(fail)

通过向节点发送 CLUSTER ADDSLOTS 命令,可以将一个或多个槽指派(assign)给节点负责

1
CLUSTER ADDSLOTS <slot> [slot ... ] 
1
2
127.0.0.1:7000> CLUSTER ADDSLOTS 0 1 2 3 4 ... 5000 # 将槽0至槽5000指派给节点7000负责
OK

命令执行细节:

  • 如果命令参数中有一个槽已经被指派给了某个节点,那么会向客户端返回错误,并终止命令执行
  • 将 slots 数组中的索引 i 上的二进制位设置为 1,就代表指派成功

节点指派

clusterNode 结构的 slots 属性和 numslot 属性记录了节点负责处理哪些槽:

1
2
3
4
5
6
struct clusterNode {
// 处理信息,一字节等于 8 位
unsigned char slots[l6384/8];
// 记录节点负责处理的槽的数量,就是 slots 数组中值为 1 的二进制位数量
int numslots;
}

slots 是一个二进制位数组(bit array),长度为 16384/8 = 2048 个字节,包含 16384 个二进制位,Redis 以 0 为起始索引,16383 为终止索引,对 slots 数组的 16384 个二进制位进行编号,并根据索引 i 上的二进制位的值来判断节点是否负责处理槽 i:

  • 在索引 i 上的二进制位的值为 1,那么表示节点负责处理槽 i
  • 在索引 i 上的二进制位的值为 0,那么表示节点不负责处理槽 i

取出和设置 slots 数组中的任意一个二进制位的值的**复杂度仅为 O(1)**,所以对于一个给定节点的 slots 数组来说,检查节点是否负责处理某个槽或者将某个槽指派给节点负责,这两个动作的复杂度都是 O(1)

传播节点的槽指派信息:一个节点除了会将自己负责处理的槽记录在 clusterNode 中,还会将自己的 slots 数组通过消息发送给集群中的其他节点,每个接收到 slots 数组的节点都会将数组保存到相应节点的 clusterNode 结构里面,因此集群中的每个节点都会知道数据库中的 16384 个槽分别被指派给了集群中的哪些节点


集群指派

集群状态 clusterState 结构中的 slots 数组记录了集群中所有 16384 个槽的指派信息,数组每一项都是一个指向 clusterNode 的指针

1
2
3
4
typedef struct clusterState {
// ...
clusterNode *slots[16384];
}
  • 如果 slots[i] 指针指向 NULL,那么表示槽 i 尚未指派给任何节点
  • 如果 slots[i] 指针指向一个 clusterNode 结构,那么表示槽 i 已经指派给该节点所代表的节点

通过该节点,程序检查槽 i 是否已经被指派或者取得负责处理槽 i 的节点,只需要访问 clusterState. slots[i] 即可,时间复杂度仅为 O(1)


集群数据

集群节点保存键值对以及键值对过期时间的方式,与单机 Redis 服务器保存键值对以及键值对过期时间的方式完全相同,但是集群节点只能使用 0 号数据库,单机服务器可以任意使用

除了将键值对保存在数据库里面之外,节点还会用 clusterState 结构中的 slots_to_keys 跳跃表来保存槽和键之间的关系

1
2
3
4
typedef struct clusterState {
// ...
zskiplist *slots_to_keys;
}

slots_to_keys 跳跃表每个节点的分值(score)都是一个槽号,而每个节点的成员(member)都是一个数据库键(按槽号升序)

  • 当节点往数据库中添加一个新的键值对时,节点就会将这个键以及键的槽号关联到 slots_to_keys 跳跃表
  • 当节点删除数据库中的某个键值对时,节点就会在 slots_to_keys 跳跃表解除被删除键与槽号的关联

通过在 slots_to_keys 跳跃表中记录各个数据库键所属的槽,可以很方便地对属于某个或某些槽的所有数据库键进行批量操作,比如 CLUSTER GETKEYSINSLOT <slot> <count> 命令返回最多 count 个属于槽 slot 的数据库键,就是通过该跳表实现


集群命令

执行命令

集群处于上线状态,客户端就可以向集群中的节点发送命令(16384 个槽全部指派就进入上线状态)

当客户端向节点发送与数据库键有关的命令时,接收命令的节点会计算出命令该键属于哪个槽,并检查这个槽是否指派给了自己

  • 如果键所在的槽正好就指派给了当前节点,那么节点直接执行这个命令
  • 反之,节点会向客户端返回一个 MOVED 错误,指引客户端转向(redirect)至正确的节点,再次发送该命令

计算键归属哪个槽的寻址算法

1
2
def slot_number(key): 			// CRC16(key) 语句计算键 key 的 CRC-16 校验和
return CRC16(key) & 16383; // 取模,十进制对16384的取余

使用 CLUSTER KEYSLOT <key> 命令可以查看一个给定键属于哪个槽,底层实现:

1
2
3
4
5
def CLUSTER_KEYSLOT(key):
// 计算槽号
slot = slot_number(key);
// 将槽号返回给客户端
reply_client(slot);

判断槽是否由当前节点负责处理:如果 clusterState.slots[i] 不等于 clusterState.myself,那么说明槽 i 并非由当前节点负责,节点会根据 clusterState.slots[i] 指向的 clusterNode 结构所记录的节点 IP 和端口号,向客户端返回 MOVED 错误


MOVED

MOVED 错误的格式为:

1
MOVED <slot> <ip>:<port>

参数 slot 为键所在的槽,ip 和 port 是负责处理槽 slot 的节点的 ip 地址和端口号

1
MOVED 12345 127.0.0.1:6380 # 表示槽 12345 正由 IP地址为 127.0.0.1, 端口号为 6380 的节点负责

当客户端接收到节点返回的 MOVED 错误时,客户端会根据 MOVED 错误中提供的 IP 地址和端口号,转至负责处理槽 slot 的节点重新发送执行的命令

  • 一个集群客户端通常会与集群中的多个节点创建套接字连接,节点转向实际上就是换一个套接字来发送命令

  • 如果客户端尚未与转向的节点创建套接字连接,那么客户端会先根据 IP 地址和端口号来连接节点,然后再进行转向

集群模式的 redis-cli 在接收到 MOVED 错误时,并不会打印出 MOVED 错误,而是根据错误自动进行节点转向,并打印出转向信息:

1
2
3
4
5
6
$ redis-cli -c -p 6379 	#集群模式
127.0.0.1:6379> SET msg "happy"
-> Redirected to slot [6257] located at 127.0.0.1:6380
OK

127.0.0.1:6379>

使用单机(stand alone)模式的 redis-cli 会打印错误,因为单机模式客户端不清楚 MOVED 错误的作用,不会进行自动转向:

1
2
3
4
5
$ redis-cli -c -p 6379 	#集群模式
127.0.0.1:6379> SET msg "happy"
(error) MOVED 6257 127.0.0.1:6380

127.0.0.1:6379>

重新分片

实现原理

Redis 集群的重新分片操作可以将任意数量已经指派给某个节点(源节点)的槽改为指派给另一个节点(目标节点),并且相关槽的键值对也会从源节点被移动到目标节点,该操作是可以在线(online)进行,在重新分片的过程中源节点和目标节点都可以处理命令请求

Redis 的集群管理软件 redis-trib 负责执行重新分片操作,redis-trib 通过向源节点和目标节点发送命令来进行重新分片操作

  • 向目标节点发送 CLUSTER SETSLOT <slot> IMPORTING <source_id> 命令,准备好从源节点导入属于槽 slot 的键值对
  • 向源节点发送 CLUSTER SETSLOT <slot> MIGRATING <target_id> 命令,让源节点准备好将属于槽 slot 的键值对迁移
  • redis-trib 向源节点发送 CLUSTER GETKEYSINSLOT <slot> <count> 命令,获得最多 count 个属于槽 slot 的键值对的键名
  • 对于每个 key,redis-trib 都向源节点发送一个 MIGRATE <target_ip> <target_port> <key_name> 0 <timeout> 命令,将被选中的键原子地从源节点迁移至目标节点
  • 重复上述步骤,直到源节点保存的所有槽 slot 的键值对都被迁移至目标节点为止
  • redis-trib 向集群中的任意一个节点发送 CLUSTER SETSLOT <slot> NODE <target _id> 命令,将槽 slot 指派给目标节点,这一指派信息会通过消息传播至整个集群,最终集群中的所有节点都直到槽 slot 已经指派给了目标节点

如果重新分片涉及多个槽,那么 redis-trib 将对每个给定的槽分别执行上面给出的步骤


命令原理

clusterState 结构的 importing_slots_from 数组记录了当前节点正在从其他节点导入的槽,migrating_slots_to 数组记录了当前节点正在迁移至其他节点的槽:

1
2
3
4
5
6
7
8
typedef struct clusterState {
// 如果 importing_slots_from[i] 的值不为 NULL,而是指向一个 clusterNode 结构,
// 那么表示当前节点正在从 clusterNode 所代表的节点导入槽 i
clusterNode *importing_slots_from[16384];

// 表示当前节点正在将槽 i 迁移至 clusterNode 所代表的节点
clusterNode *migrating_slots_to[16384];
}

CLUSTER SETSLOT <slot> IMPORTING <source_id> 命令:将目标节点 clusterState.importing_slots_from[slot] 的值设置为 source_id 所代表节点的 clusterNode 结构

CLUSTER SETSLOT <slot> MIGRATING <target_id> 命令:将源节点 clusterState.migrating_slots_to[slot] 的值设置为target_id 所代表节点的 clusterNode 结构


ASK 错误

重新分片期间,源节点向目标节点迁移一个槽的过程中,可能出现被迁移槽的一部分键值对保存在源节点,另一部分保存在目标节点

客户端向源节点发送命令请求,并且命令要处理的数据库键属于被迁移的槽:

  • 源节点会先在数据库里面查找指定键,如果找到的话,就直接执行客户端发送的命令

  • 未找到会检查 clusterState.migrating_slots_to[slot],看键 key 所属的槽 slot 是否正在进行迁移

  • 槽 slot 正在迁移则源节点将向客户端返回一个 ASK 错误,指引客户端转向正在导入槽的目标节点

    1
    ASK <slot> <ip:port>
  • 接到 ASK 错误的客户端,会根据错误提供的 IP 地址和端口号转向目标节点,首先向目标节点发送一个 ASKING 命令,再重新发送原本想要执行的命令

和 MOVED 错误情况类似,集群模式的 redis-cli 在接到 ASK 错误时不会打印错误进行自动转向;单机模式的 redis-cli 会打印错误

对比 MOVED 错误:

  • MOVED 错误代表槽的负责权已经从一个节点转移到了另一个节点,转向是一种持久性的转向

  • ASK 错误只是两个节点在迁移槽的过程中使用的一种临时措施,ASK 的转向不会对客户端今后发送关于槽 slot 的命令请求产生任何影响,客户端仍然会将槽 slot 的命令请求发送至目前负责处理槽 slot 的节点,除非 ASK 错误再次出现


ASKING

客户端不发送 ASKING 命令,而是直接发送执行的命令,那么客户端发送的命令将被节点拒绝执行,并返回 MOVED 错误

ASKING 命令作用是打开发送该命令的客户端的 REDIS_ASKING 标识,该命令的伪代码实现:

1
2
3
4
5
def ASKING ():
// 打开标识
client.flags |= REDIS_ASKING
// 向客户端返回OK回复
reply("OK")

当前节点正在导入槽 slot,并且发送命令的客户端带有 REDIS_ASKING 标识,那么节点将破例执行这个关于槽 slot 的命令一次

客户端的 REDIS_ASKING 标识是一次性标识,当节点执行了一个带有 REDIS_ASKING 标识的客户端发送的命令之后,该客户端的 REDIS_ASKING 标识就会被移除


高可用

节点复制

Redis 集群中的节点分为主节点(master)和从节点(slave),其中主节点用于处理槽,而从节点则用于复制主节点,并在被复制的主节点下线时,代替下线主节点继续处理命令请求

1
CLUSTER REPLICATE <node_id> 

向一个节点发送命令可以让接收命令的节点成为 node_id 所指定节点的从节点,并开始对主节点进行复制

  • 接受命令的节点首先会在的 clusterState.nodes 字典中找到 node_id 所对应节点的 clusterNode 结构,并将自己的节点中的 clusterState.myself.slaveof 指针指向这个结构,记录这个节点正在复制的主节点

  • 节点会修改 clusterState.myself.flags 中的属性,关闭 REDIS_NODE_MASTER 标识,打开 REDIS_NODE_SLAVE 标识

  • 节点会调用复制代码,对主节点进行复制(节点的复制功能和单机 Redis 服务器的使用了相同的代码)

一个节点成为从节点,并开始复制某个主节点这一信息会通过消息发送给集群中的其他节点,最终集群中的所有节点都会知道某个从节点正在复制某个主节点

主节点的 clusterNode 结构的 slaves 属性和 numslaves 属性中记录正在复制这个主节点的从节点名单:

1
2
3
4
5
6
7
struct clusterNode {
// 正在复制这个主节点的从节点数量
int numslaves;

// 数组项指向一个正在复制这个主节点的从节点的clusterNode结构
struct clusterNode **slaves;
}

故障检测

集群中的每个节点都会定期地向集群中的其他节点发送 PING 消息,来检测对方是否在线,如果接收 PING 的节点没有在规定的时间内返回 PONG 消息,那么发送消息节点就会将接收节点标记为疑似下线(probable fail, PFAIL)

集群中的节点会互相发送消息,来交换集群中各个节点的状态信息,当一个主节点 A 通过消息得知主节点 B 认为主节点 C 进入了疑似下线状态时,主节点 A 会在 clusterState.nodes 字典中找到主节点 C 所对应的节点,并将主节点 B 的下线报告(failure report)添加到 clusterNode.fail_reports 链表里面

1
2
3
4
5
6
7
8
9
10
11
12
13
struct clusterNode {
// 一个链表,记录了所有其他节点对该节点的下线报告
list *fail_reports;
}
// 每个下线报告由一个 clusterNodeFailReport 结构表示
struct clusterNodeFailReport {
// 报告目标节点巳经下线的节点
struct clusterNode *node;

// 最后一次从node节点收到下线报告的时间
// 程序使用这个时间戳来检查下线报告是否过期,与当前时间相差太久的下线报告会被删除
mstime_t time;
};

集群里半数以上负责处理槽的主节点都将某个主节点 X 报告为疑似下线,那么 X 将被标记为已下线(FAIL),将 X 标记为已下线的节点会向集群广播一条关于主节点 X 的 FAIL 消息,所有收到消息的节点都会将 X 标记为已下线


故障转移

当一个从节点发现所属的主节点进入了已下线状态,从节点将开始对下线主节点进行故障转移,执行步骤:

  • 下属的从节点通过选举产生一个节点
  • 被选中的从节点会执行 SLAVEOF no one 命令,成为新的主节点
  • 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己
  • 新的主节点向集群广播一条 PONG 消息,让集群中的其他节点知道当前节点变成了主节点,并且接管了下线节点负责处理的槽
  • 新的主节点开始接收有关的命令请求,故障转移完成

选举算法

集群选举新的主节点的规则:

  • 集群的配置纪元是一个自增的计数器,初始值为 0
  • 当集群里某个节点开始一次故障转移,集群的配置纪元就是增加一
  • 每个配置纪元里,集群中每个主节点都有一次投票的机会,而第一个向主节点要求投票的从节点将获得该主节点的投票
  • 具有投票权的主节点是必须具有正在处理的槽
  • 集群里有 N 个具有投票权的主节点,那么当一个从节点收集到大于等于 N/2+1 张支持票时,从节点就会当选
  • 每个配置纪元里,具有投票权的主节点只能投一次票,所以获得一半以上票的节点只会有一个

选举流程:

  • 当某个从节点发现正在复制的主节点进入已下线状态时,会向集群广播一条 CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST 消息,要求所有收到这条消息、并且具有投票权的主节点向这个从节点投票
  • 如果主节点尚未投票给其他从节点,将向要求投票的从节点返回一条 CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK 消息,表示这个主节点支持从节点成为新的主节点
  • 如果从节点获取到了半数以上的选票,则会当选新的主节点
  • 如果一个配置纪元里没有从节点能收集到足够多的支待票,那么集群进入一个新的配置纪元,并再次进行选举,直到选出新的主节点

选举新主节点的方法和选举领头 Sentinel 的方法非常相似,两者都是基于 Raft 算法的领头选举(eader election)方法实现的


消息机制

消息结构

集群中的各个节点通过发送和接收消息(message)来进行通信,将发送消息的节点称为发送者(sender),接收消息的节点称为接收者(receiver)

节点发送的消息主要有:

  • MEET 消息:当发送者接到客户端发送的 CLUSTER MEET 命令时,会向接收者发送 MEET 消息,请求接收者加入到发送者当前所处的集群里

  • PING 消息:集群里的每个节点默认每隔一秒钟就会从已知节点列表中随机选出五个,然后对这五个节点中最长时间没有发送过 PING 消息的节点发送 PING,以此来随机检测被选中的节点是否在线

    如果节点 A 最后一次收到节点 B 发送的 PONG 消息的时间,距离当前已经超过了节点 A 的 cluster-node­-timeout 设置时长的一半,那么 A 也会向 B 发送 PING 消息,防止 A 因为长时间没有随机选中 B 发送 PING,而导致对节点 B 的信息更新滞后

  • PONG 消息:当接收者收到 MEET 消息或者 PING 消息时,为了让发送者确认已经成功接收消息,会向发送者返回一条 PONG;节点也可以通过向集群广播 PONG 消息来让集群中的其他节点立即刷新关于这个节点的认识(从升级为主)

  • FAIL 消息:当一个主节点 A 判断另一个主节点 B 已经进入 FAIL 状态时,节点 A 会向集群广播一条 B 节点的 FAIL 信息

  • PUBLISH 消息:当节点接收到一个 PUBLISH 命令时,节点会执行这个命令并向集群广播一条 PUBLISH 消息,接收到 PUBLISH 消息的节点都会执行相同的 PUBLISH 命令


消息头

节点发送的所有消息都由一个消息头包裹,消息头除了包含消息正文之外,还记录了消息发送者自身的一些信息

消息头:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
typedef struct clusterMsg {
// 消息的长度(包括这个消息头的长度和消息正文的长度)
uint32_t totlen;
// 消息的类型
uint16_t type;
// 消息正文包含的节点信息数量,只在发送MEET、PING、PONG这三种Gossip协议消息时使用
uint16_t count;

// 发送者所处的配置纪元
uint64_t currentEpoch;
// 如果发送者是一个主节点,那么这里记录的是发送者的配置纪元
// 如果发送者是一个从节点,那么这里记录的是发送者正在复制的主节点的配置纪元
uint64_t configEpoch;

// 发送者的名字(ID)
char sender[REDIS CLUSTER NAMELEN];
// 发送者目前的槽指派信息
unsigned char myslots[REDIS_CLUSTER_SLOTS/8];

// 如果发送者是一个从节点,那么这里记录的是发送者正在复制的主节点的名字
// 如果发送者是一个主节点,那么这里记录的是 REDIS_NODE_NULL_NAME,一个 40 宇节长值全为 0 的字节数组
char slaveof[REDIS_CLUSTER_NAMELEN];

// 发送者的端口号
uint16_t port;
// 发送者的标识值
uint16_t flags;
//发送者所处集群的状态
unsigned char state;
// 消息的正文(或者说, 内容)
union clusterMsgData data;
}

clusterMsg 结构的 currentEpoch、sender、myslots 等属性记录了发送者的节点信息,接收者会根据这些信息在 clusterState.nodes 字典里找到发送者对应的 clusterNode 结构,并对结构进行更新,比如传播节点的槽指派信息

消息正文:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
union clusterMsgData {
// MEET、PING、PONG 消息的正文
struct {
// 每条 MEET、PING、PONG 消息都包含两个 clusterMsgDataGossip 结构
clusterMsgDataGossip gossip[1];
} ping;

// FAIL 消息的正文
struct {
clusterMsgDataFail about;
} fail;

// PUBLISH 消息的正文
struct {
clusterMsgDataPublish msg;
} publish;

// 其他消息正文...
}

Gossip

Redis 集群中的各个节点通过 Gossip 协议来交换各自关于不同节点的状态信息,其中 Gossip 协议由 MEET、PING、PONG 消息实现,三种消息使用相同的消息正文,所以节点通过消息头的 type 属性来判断消息的具体类型

发送者发送这三种消息时,会从已知节点列表中随机选出两个节点(主从都可以),将两个被选中节点信息保存到两个 Gossip 结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typedef struct clusterMsgDataGossip {
// 节点的名字
char nodename[REDIS CLUSTER NAMELEN];

// 最后一次向该节点发送PING消息的时间戳
uint32_t ping_sent;
// 最后一次从该节点接收到PONG消息的时间戳
uint32_t pong_received;

// 节点的IP地址
char ip[16];
// 节点的端口号
uint16_t port;
// 节点的标识值
uint16_t flags;
}

当接收者收到消息时,会访问消息正文中的两个数据结构,来进行相关操作

  • 如果被选中节点不存在于接收者的已知节点列表,接收者将根据结构中记录的 IP 地址和端口号,与节点进行握手
  • 如果存在,根据 Gossip 结构记录的信息对节点所对应的 clusterNode 结构进行更新

FAIL

在集群的节点数量比较大的情况下,使用 Gossip 协议来传播节点的已下线信息会带来一定延迟,因为 Gossip 协议消息通常需要一段时间才能传播至整个集群,所以通过发送 FAIL消息可以让集群里的所有节点立即知道某个主节点已下线,从而尽快进行其他操作

FAIL 消息的正文由 clusterMsgDataFail 结构表示,该结构只有一个属性,记录了已下线节点的名字

1
2
3
typedef struct clusterMsgDataFail {
char nodename[REDIS_CLUSTER_NAMELEN)];
};

因为传播下线信息不需要其他属性,所以节省了传播的资源


PUBLISH

当客户端向集群中的某个节点发送命令,接收到 PUBLISH 命令的节点不仅会向 channel 频道发送消息 message,还会向集群广播一条 PUBLISH 消息,所有接收到这条 PUBLISH 消息的节点都会向 channel 频道发送 message 消息,最终集群中所有节点都发了

1
PUBLISH <channel> <message> 

PUBLISH 消息的正文由 clusterMsgDataPublish 结构表示:

1
2
3
4
5
6
7
8
9
10
11
typedef struct clusterMsgDataPublish {
// channel参数的长度
uint32_t channel_len;
// message参数的长度
uint32_t message_len;

// 定义为8字节只是为了对齐其他消息结构,实际的长度由保存的内容决定
// bulk_data 的 0 至 channel_len-1 字节保存的是channel参数
// bulk_data的 channel_len 字节至 channel_len + message_len-1 字节保存的则是message参数
unsigned char bulk_data[8];
}

让集群的所有节点执行相同的 PUBLISH 命令,最简单的方法就是向所有节点广播相同的 PUBLISH 命令,这也是 Redis 复制 PUBLISH 命令时所使用的,但是这种做法并不符合 Redis 集群的各个节点通过发送和接收消息来进行通信的规则


脑裂问题

脑裂指在主从集群中,同时有两个相同的主节点能接收写请求,导致客户端不知道应该往哪个主节点写入数据,导致不同客户端往不同的主节点上写入数据

  • 原主节点并没有真的发生故障,由于某些原因无法处理请求(CPU 利用率很高、自身阻塞),无法按时响应心跳请求,被哨兵/集群主节点错误的判断为下线
  • 在被判断下线之后,原主库又重新开始处理请求了,哨兵/集群主节点还没有完成主从切换,客户端仍然可以和原主库通信,客户端发送的写操作就会在原主库上写入数据,造成脑裂问题

数据丢失问题:从库一旦升级为新主库,哨兵就会让原主库执行 slave of 命令,和新主库重新进行全量同步,原主库需要清空本地的数据,加载新主库发送的 RDB 文件,所以原主库在主从切换期间保存的新写数据就丢失了

预防脑裂:在主从集群部署时,合理地配置参数 min-slaves-to-write 和 min-slaves-max-lag

  • 假设从库有 K 个,可以将 min-slaves-to-write 设置为 K/2+1(如果 K 等于 1,就设为 1)
  • 将 min-slaves-max-lag 设置为十几秒(例如 10~20s)

结构搭建

整体框架:

  • 配置服务器(3 主 3 从)
  • 建立通信(Meet)
  • 分槽(Slot)
  • 搭建主从(master-slave)

创建集群 conf 配置文件:

  • redis-6501.conf

    1
    2
    3
    4
    5
    6
    7
    8
    port 6501
    dir "/redis/data"
    dbfilename "dump-6501.rdb"
    cluster-enabled yes
    cluster-config-file "cluster-6501.conf"
    cluster-node-timeout 5000

    #其他配置文件参照上面的修改端口即可,内容完全一样
  • 服务端启动:

    1
    redis-server config_file_name
  • 客户端启动:

    1
    redis-cli -p 6504 -c

cluster 配置:

  • 是否启用 cluster,加入 cluster 节点

    1
    cluster-enabled yes|no
  • cluster 配置文件名,该文件属于自动生成,仅用于快速查找文件并查询文件内容

    1
    cluster-config-file filename
  • 节点服务响应超时时间,用于判定该节点是否下线或切换为从节点

    1
    cluster-node-timeout milliseconds
  • master 连接的 slave 最小数量

    1
    cluster-migration-barrier min_slave_number

客户端启动命令:

cluster 节点操作命令(客户端命令):

  • 查看集群节点信息

    1
    cluster nodes
  • 更改 slave 指向新的 master

    1
    cluster replicate master-id
  • 发现一个新节点,新增 master

    1
    cluster meet ip:port
  • 忽略一个没有 solt 的节点

    1
    cluster forget server_id
  • 手动故障转移

    1
    cluster failover

集群操作命令(Linux):

  • 创建集群

    1
    redis-cli –-cluster create masterhost1:masterport1 masterhost2:masterport2  masterhost3:masterport3 [masterhostn:masterportn …] slavehost1:slaveport1  slavehost2:slaveport2 slavehost3:slaveport3 -–cluster-replicas n

    注意:master 与 slave 的数量要匹配,一个 master 对应 n 个 slave,由最后的参数 n 决定。master 与 slave 的匹配顺序为第一个 master 与前 n 个 slave 分为一组,形成主从结构

  • 添加 master 到当前集群中,连接时可以指定任意现有节点地址与端口

    1
    redis-cli --cluster add-node new-master-host:new-master-port now-host:now-port
  • 添加 slave

    1
    redis-cli --cluster add-node new-slave-host:new-slave-port master-host:master-port --cluster-slave --cluster-master-id masterid
  • 删除节点,如果删除的节点是 master,必须保障其中没有槽 slot

    1
    redis-cli --cluster del-node del-slave-host:del-slave-port del-slave-id
  • 重新分槽,分槽是从具有槽的 master 中划分一部分给其他 master,过程中不创建新的槽

    1
    redis-cli --cluster reshard new-master-host:new-master:port --cluster-from src-  master-id1, src-master-id2, src-master-idn --cluster-to target-master-id --  cluster-slots slots

    注意:将需要参与分槽的所有 masterid 不分先后顺序添加到参数中,使用 , 分隔,指定目标得到的槽的数量,所有的槽将平均从每个来源的 master 处获取

  • 重新分配槽,从具有槽的 master 中分配指定数量的槽到另一个 master 中,常用于清空指定 master 中的槽

    1
    redis-cli --cluster reshard src-master-host:src-master-port --cluster-from src-  master-id --cluster-to target-master-id --cluster-slots slots --cluster-yes

其他操作

发布订阅

基本指令

Redis 发布订阅(pub/sub)是一种消息通信模式:发送者(pub)发送消息,订阅者(sub)接收消息

Redis 客户端可以订阅任意数量的频道,每当有客户端向被订阅的频道发送消息(message)时,频道的所有订阅者都会收到消息

操作过程:

  • 打开一个客户端订阅 channel1:SUBSCRIBE channel1

  • 打开另一个客户端,给 channel1 发布消息 hello:PUBLISH channel1 hello

  • 第一个客户端可以看到发送的消息

客户端还可以通过 PSUBSCRIBE 命令订阅一个或多个模式,每当有其他客户端向某个频道发送消息时,消息不仅会被发送给这个频道的所有订阅者,还会被发送给所有与这个频道相匹配的模式的订阅者,比如 PSUBSCRIBE channel* 订阅模式,与 channel1 匹配

注意:发布的消息没有持久化,所以订阅的客户端只能收到订阅后发布的消息


频道操作

Redis 将所有频道的订阅关系都保存在服务器状态的 pubsub_channels 字典里,键是某个被订阅的频道,值是一个记录所有订阅这个频道的客户端链表

1
2
3
4
struct redisServer {
// 保存所有频道的订阅关系,
dict *pubsub_channels;
}

客户端执行 SUBSCRIBE 命令订阅某个或某些频道,服务器会将客户端与频道进行关联:

  • 频道已经存在,直接将客户端添加到链表末尾
  • 频道还未有任何订阅者,在字典中为频道创建一个键值对,再将客户端添加到链表

UNSUBSCRIBE 命令用来退订某个频道,服务器将从 pubsub_channels 中解除客户端与被退订频道之间的关联


模式操作

Redis 服务器将所有模式的订阅关系都保存在服务器状态的 pubsub_patterns 属性里

1
2
3
4
5
6
7
8
9
10
11
struct redisServer {
// 保存所有模式订阅关系,链表中每个节点是一个 pubsubPattern
list *pubsub_patterns;
}

typedef struct pubsubPattern {
// 订阅的客户端
redisClient *client;
// 被订阅的模式,比如 channel*
robj *pattern;
}

客户端执行 PSUBSCRIBE 命令订阅某个模式,服务器会新建一个 pubsubPattern 结构并赋值,放入 pubsub_patterns 链表结尾

模式的退订命令 PUNSUBSCRIBE 是订阅命令的反操作,服务器在 pubsub_patterns 链表中查找并删除对应的结构


发送消息

Redis 客户端执行 PUBLISH <channel> <message> 命令将消息 message发送给频道 channel,服务器会执行:

  • 在 pubsub_channels 字典里找到频道 channel 的订阅者名单,将消息 message 发送给所有订阅者
  • 遍历整个 pubsub_patterns 链表,查找与 channel 频道相匹配的模式,并将消息发送给所有订阅了这些模式的客户端
1
2
3
4
5
// 如果频道和模式相匹配
if match(channel, pubsubPattern.pattern) {
// 将消息发送给订阅该模式的客户端
send_message(pubsubPattern.client, message);
}

查看信息

PUBSUB 命令用来查看频道或者模式的相关信息

PUBSUB CHANNELS [pattern] 返回服务器当前被订阅的频道,其中 pattern 参数是可选的

  • 如果不给定 pattern 参数,那么命令返回服务器当前被订阅的所有频道
  • 如果给定 pattern 参数,那么命令返回服务器当前被订阅的频道中与 pattern 模式相匹配的频道

PUBSUB NUMSUB [channel-1 channel-2 ... channel-n] 命令接受任意多个频道作为输入参数,并返回这些频道的订阅者数量

PUBSUB NUMPAT 命令用于返回服务器当前被订阅模式的数量


ACL 指令

Redis ACL 是 Access Control List(访问控制列表)的缩写,该功能允许根据可以执行的命令和可以访问的键来限制某些连接

  • acl cat:查看添加权限指令类别

  • acl whoami:查看当前用户

  • acl setuser username on >password ~cached:* +get:设置有用户名、密码、ACL 权限(只能 get)


监视器

MONITOR 命令,可以将客户端变为一个监视器,实时地接收并打印出服务器当前处理的命令请求的相关信息

1
2
3
4
5
6
7
8
9
// 实现原理
def MONITOR():
// 打开客户端的监视器标志
client.flags |= REDIS_MONITOR

// 将客户端添加到服务器状态的 redisServer.monitors链表的末尾
server.monitors.append(client)
// 向客户端返回 ok
send_reply("OK")

服务器每次处理命令请求都会调用 replicationFeedMonitors 函数,函数将被处理的命令请求的相关信息发送给各个监视器

1
2
3
4
5
6
7
8
9
redis> MONITOR 
OK
1378822099.421623 [0 127.0.0.1:56604] "PING"
1378822105.089572 [0 127.0.0.1:56604] "SET" "msg" "hello world"
1378822109.036925 [0 127.0.0.1:56604] "SET" "number" "123"
1378822140.649496 (0 127.0.0.1:56604] "SADD" "fruits" "Apple" "Banana" "Cherry"
1378822154.117160 [0 127.0.0.1:56604] "EXPIRE" "msg" "10086"
1378822257.329412 [0 127.0.0.1:56604] "KEYS" "*"
1378822258.690131 [0 127.0.0.1:56604] "DBSIZE"

批处理

Redis 的管道 Pipeline 机制可以一次处理多条指令

  • Pipeline 中的多条命令非原子性,因为在向管道内添加命令时,其他客户端的发送的命令仍然在执行
  • 原生批命令(mset 等)是服务端实现,而 pipeline 需要服务端与客户端共同完成

使用 Pipeline 封装的命令数量不能太多,数据量过大会增加客户端的等待时间,造成网络阻塞,Jedis 中的 Pipeline 使用方式:

1
2
3
4
5
6
7
8
9
10
// 创建管道
Pipeline pipeline = jedis.pipelined();
for (int i = 1; i <= 100000; i++) {
// 放入命令到管道
pipeline.set("key_" + i, "value_" + i);
if (i % 1000 == 0) {
// 每放入1000条命令,批量执行
pipeline.sync();
}
}

集群下模式下,批处理命令的多个 key 必须落在一个插槽中,否则就会导致执行失败,N 条批处理命令的优化方式:

  • 串行命令:for 循环遍历,依次执行每个命令
  • 串行 slot:在客户端计算每个 key 的 slot,将 slot 一致的分为一组,每组都利用 Pipeline 批处理,串行执行各组命令
  • 并行 slot:在客户端计算每个 key 的 slot,将 slot 一致的分为一组,每组都利用 Pipeline 批处理,并行执行各组命令
  • hash_tag:将所有 key 设置相同的 hash_tag,则所有 key 的 slot 一定相同
耗时 优点 缺点
串行命令 N 次网络耗时 + N 次命令耗时 实现简单 耗时久
串行 slot m 次网络耗时 + N 次命令耗时,m = key 的 slot 个数 耗时较短 实现稍复杂
并行 slot 1 次网络耗时 + N 次命令耗时 耗时非常短 实现复杂
hash_tag 1 次网络耗时 + N 次命令耗时 耗时非常短、实现简单 容易出现数据倾斜

解决方案

缓存方案

缓存模式

旁路缓存

缓存本质:弥补 CPU 的高算力和 IO 的慢读写之间巨大的鸿沟

旁路缓存模式 Cache Aside Pattern 是平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景

Cache Aside Pattern 中服务端需要同时维系 DB 和 cache,并且是以 DB 的结果为准

  • 写操作:先更新 DB,然后直接删除 cache
  • 读操作:从 cache 中读取数据,读取到就直接返回;读取不到就从 DB 中读取数据返回,并放到 cache

时序导致的不一致问题:

  • 在写数据的过程中,不能先删除 cache 再更新 DB,因为会造成缓存的不一致。比如请求 1 先写数据 A,请求 2 随后读数据 A,当请求 1 删除 cache 后,请求 2 直接读取了 DB,此时请求 1 还没写入 DB(延迟双删)

  • 在写数据的过程中,先更新 DB 再删除 cache 也会出现问题,但是概率很小,因为缓存的写入速度非常快

旁路缓存的缺点:

  • 首次请求数据一定不在 cache 的问题,一般采用缓存预热的方法,将热点数据可以提前放入 cache 中
  • 写操作比较频繁的话导致 cache 中的数据会被频繁被删除,影响缓存命中率

删除缓存而不是更新缓存的原因:每次更新数据库都更新缓存,造成无效写操作较多(懒惰加载,需要的时候再放入缓存)


读写穿透

读写穿透模式 Read/Write Through Pattern:服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中,cache 负责将此数据同步写入 DB,从而减轻了应用程序的职责

  • 写操作:先查 cache,cache 中不存在,直接更新 DB;cache 中存在则先更新 cache,然后 cache 服务更新 DB(同步更新 cache 和 DB)

  • 读操作:从 cache 中读取数据,读取到就直接返回 ;读取不到先从 DB 加载,写入到 cache 后返回响应

    Read-Through Pattern 实际只是在 Cache-Aside Pattern 之上进行了封装。在 Cache-Aside Pattern 下,发生读请求的时候,如果 cache 中不存在对应的数据,是由客户端负责把数据写入 cache,而 Read Through Pattern 则是 cache 服务自己来写入缓存的,对客户端是透明的

Read-Through Pattern 也存在首次不命中的问题,采用缓存预热解决


异步缓存

异步缓存写入 Write Behind Pattern 由 cache 服务来负责 cache 和 DB 的读写,对比读写穿透不同的是 Write Behind Caching 是只更新缓存,不直接更新 DB,改为异步批量的方式来更新 DB,可以减小写的成本

缺点:这种模式对数据一致性没有高要求,可能出现 cache 还没异步更新 DB,服务就挂掉了

应用:

  • DB 的写性能非常高,适合一些数据经常变化又对数据一致性要求不高的场景,比如浏览量、点赞量

  • MySQL 的 InnoDB Buffer Pool 机制用到了这种策略


缓存一致

使用缓存代表不需要强一致性,只需要最终一致性

缓存不一致的方法:

  • 数据库和缓存数据强一致场景:

    • 同步双写:更新 DB 时同样更新 cache,保证在一个事务中,通过加锁来保证更新 cache 时不存在线程安全问题

    • 延迟双删:先淘汰缓存再写数据库,休眠 1 秒再次淘汰缓存,可以将 1 秒内造成的缓存脏数据再次删除

    • 异步通知:

      • 基于 MQ 的异步通知:对数据的修改后,代码需要发送一条消息到 MQ 中,缓存服务监听 MQ 消息
      • Canal 订阅 MySQL binlog 的变更上报给 Kafka,系统监听 Kafka 消息触发缓存失效,或者直接将变更发送到处理服务,没有任何代码侵入

      低耦合,可以同时通知多个缓存服务,但是时效性一般,可能存在中间不一致状态

  • 低一致性场景:

    • 更新 DB 的时候同样更新 cache,但是给缓存加一个比较短的过期时间,这样就可以保证即使数据不一致影响也比较小
    • 使用 Redis 自带的内存淘汰机制

缓存问题

缓存预热

场景:宕机,服务器启动后迅速宕机

问题排查:

  1. 请求数量较高,大量的请求过来之后都需要去从缓存中获取数据,但是缓存中又没有,此时从数据库中查找数据然后将数据再存入缓存,造成了短期内对 redis 的高强度操作从而导致问题

  2. 主从之间数据吞吐量较大,数据同步操作频度较高

解决方案:

  • 前置准备工作:

    1. 日常例行统计数据访问记录,统计访问频度较高的热点数据

    2. 利用 LRU 数据删除策略,构建数据留存队列例如:storm 与 kafka 配合

  • 准备工作:

    1. 将统计结果中的数据分类,根据级别,redis 优先加载级别较高的热点数据

    2. 利用分布式多服务器同时进行数据读取,提速数据加载过程

    3. 热点数据主从同时预热

  • 实施:

    1. 使用脚本程序固定触发数据预热过程

    2. 如果条件允许,使用了 CDN(内容分发网络),效果会更好

总的来说:缓存预热就是系统启动前,提前将相关的缓存数据直接加载到缓存系统。避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题,用户直接查询事先被预热的缓存数据


缓存雪崩

场景:数据库服务器崩溃,一连串的问题会随之而来

问题排查:在一个较短的时间内,缓存中较多的 key 集中过期,此周期内请求访问过期的数据 Redis 未命中,Redis 向数据库获取数据,数据库同时收到大量的请求无法及时处理。

解决方案:

  1. 加锁,慎用
  2. 设置热点数据永远不过期,如果缓存数据库是分布式部署,将热点数据均匀分布在不同搞得缓存数据库中
  3. 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生
  4. 构建多级缓存架构,Nginx 缓存 + Redis 缓存 + ehcache 缓存
  5. 灾难预警机制,监控 Redis 服务器性能指标,CPU 使用率、内存容量、平均响应时间、线程数
  6. 限流、降级:短时间范围内牺牲一些客户体验,限制一部分请求访问,降低应用服务器压力,待业务低速运转后再逐步放开访问

总的来说:缓存雪崩就是瞬间过期数据量太大,导致对数据库服务器造成压力。如能够有效避免过期时间集中,可以有效解决雪崩现象的出现(约 40%),配合其他策略一起使用,并监控服务器的运行数据,根据运行记录做快速调整。


缓存击穿

缓存击穿也叫热点 Key 问题

  1. Redis 中某个 key 过期,该 key 访问量巨大

  2. 多个数据请求从服务器直接压到 Redis 后,均未命中

  3. Redis 在短时间内发起了大量对数据库中同一数据的访问

解决方案:

  1. 预先设定:以电商为例,每个商家根据店铺等级,指定若干款主打商品,在购物节期间,加大此类信息 key 的过期时长 注意:购物节不仅仅指当天,以及后续若干天,访问峰值呈现逐渐降低的趋势

  2. 现场调整:监控访问量,对自然流量激增的数据延长过期时间或设置为永久性 key

  3. 后台刷新数据:启动定时任务,高峰期来临之前,刷新数据有效期,确保不丢失

  4. 二级缓存:设置不同的失效时间,保障不会被同时淘汰就行

  5. 加锁:分布式锁,防止被击穿,但是要注意也是性能瓶颈,慎重

总的来说:缓存击穿就是单个高热数据过期的瞬间,数据访问量较大,未命中 Redis 后,发起了大量对同一数据的数据库访问,导致对数据库服务器造成压力。应对策略应该在业务数据分析与预防方面进行,配合运行监控测试与即时调整策略,毕竟单个 key 的过期监控难度较高,配合雪崩处理策略即可


缓存穿透

场景:系统平稳运行过程中,应用服务器流量随时间增量较大,Redis 服务器命中率随时间逐步降低,Redis 内存平稳,内存无压力,Redis 服务器 CPU 占用激增,数据库服务器压力激增,数据库崩溃

问题排查:

  1. Redis 中大面积出现未命中

  2. 出现非正常 URL 访问

问题分析:

  • 访问了不存在的数据,跳过了 Redis 缓存,数据库页查询不到对应数据
  • Redis 获取到 null 数据未进行持久化,直接返回
  • 出现黑客攻击服务器

解决方案:

  1. 缓存 null:对查询结果为 null 的数据进行缓存,设定短时限,例如 30-60 秒,最高 5 分钟

  2. 白名单策略:提前预热各种分类数据 id 对应的 bitmaps,id 作为 bitmaps 的 offset,相当于设置了数据白名单。当加载正常数据时放行,加载异常数据时直接拦截(效率偏低),也可以使用布隆过滤器(有关布隆过滤器的命中问题对当前状况可以忽略)

  3. 实时监控:实时监控 Redis 命中率(业务正常范围时,通常会有一个波动值)与 null 数据的占比

    • 非活动时段波动:通常检测 3-5 倍,超过 5 倍纳入重点排查对象
    • 活动时段波动:通常检测10-50 倍,超过 50 倍纳入重点排查对象

    根据倍数不同,启动不同的排查流程。然后使用黑名单进行防控

  4. key 加密:临时启动防灾业务 key,对 key 进行业务层传输加密服务,设定校验程序,过来的 key 校验;例如每天随机分配 60 个加密串,挑选 2 到 3 个,混淆到页面数据 id 中,发现访问 key 不满足规则,驳回数据访问

总的来说:缓存击穿是指访问了不存在的数据,跳过了合法数据的 Redis 数据缓存阶段,每次访问数据库,导致对数据库服务器造成压力。通常此类数据的出现量是一个较低的值,当出现此类情况以毒攻毒,并及时报警。无论是黑名单还是白名单,都是对整体系统的压力,警报解除后尽快移除

参考视频:https://www.bilibili.com/video/BV15y4y1r7X3


Key 设计

大 Key:通常以 Key 的大小和 Key 中成员的数量来综合判定,引发的问题:

  • 客户端执行命令的时长变慢
  • Redis 内存达到 maxmemory 定义的上限引发操作阻塞或重要的 Key 被逐出,甚至引发内存溢出(OOM)
  • 集群架构下,某个数据分片的内存使用率远超其他数据分片,使数据分片的内存资源不均衡
  • 对大 Key 执行读请求,会使 Redis 实例的带宽使用率被占满,导致自身服务变慢,同时易波及相关的服务
  • 对大 Key 执行删除操作,会造成主库较长时间的阻塞,进而可能引发同步中断或主从切换

热 Key:通常以其接收到的 Key 被请求频率来判定,引发的问题:

  • 占用大量的 CPU 资源,影响其他请求并导致整体性能降低
  • 分布式集群架构下,产生访问倾斜,即某个数据分片被大量访问,而其他数据分片处于空闲状态,可能引起该数据分片的连接数被耗尽,新的连接建立请求被拒绝等问题
  • 在抢购或秒杀场景下,可能因商品对应库存 Key 的请求量过大,超出 Redis 处理能力造成超卖
  • 热 Key 的请求压力数量超出 Redis 的承受能力易造成缓存击穿,即大量请求将被直接指向后端的存储层,导致存储访问量激增甚至宕机,从而影响其他业务

参考文档:https://help.aliyun.com/document_detail/353223.html


性能指标

Redis 中的监控指标如下:

  • 性能指标:Performance

    响应请求的平均时间:

    1
    latency

    平均每秒处理请求总数:

    1
    instantaneous_ops_per_sec

    缓存查询命中率(通过查询总次数与查询得到非nil数据总次数计算而来):

    1
    hit_rate(calculated)
  • 内存指标:Memory

    当前内存使用量:

    1
    used_memory

    内存碎片率(关系到是否进行碎片整理):

    1
    mem_fragmentation_ratio

    为避免内存溢出删除的key的总数量:

    1
    evicted_keys

    基于阻塞操作(BLPOP等)影响的客户端数量:

    1
    blocked_clients
  • 基本活动指标:Basic_activity

    当前客户端连接总数:

    1
    connected_clients

    当前连接 slave 总数:

    1
    connected_slaves

    最后一次主从信息交换距现在的秒:

    1
    master_last_io_seconds_ago

    key 的总数:

    1
    keyspace
  • 持久性指标:Persistence

    当前服务器其最后一次 RDB 持久化的时间:

    1
    rdb_last_save_time

    当前服务器最后一次 RDB 持久化后数据变化总量:

    1
    rdb_changes_since_last_save
  • 错误指标:Error

    被拒绝连接的客户端总数(基于达到最大连接值的因素):

    1
    rejected_connections

    key未命中的总次数:

    1
    keyspace_misses

    主从断开的秒数:

    1
    master_link_down_since_seconds

要对 Redis 的相关指标进行监控,我们可以采用一些用具:

  • CloudInsight Redis
  • Prometheus
  • Redis-stat
  • Redis-faina
  • RedisLive
  • zabbix

命令工具:

  • benchmark

    测试当前服务器的并发性能:

    1
    redis-benchmark [-h ] [-p ] [-c ] [-n <requests]> [-k ]

    范例:100 个连接,5000 次请求对应的性能

    1
    redis-benchmark -c 100 -n 5000

  • redis-cli

    monitor:启动服务器调试信息

    1
    monitor

    slowlog:慢日志

    1
    slowlog [operator]    #获取慢查询日志
    • get :获取慢查询日志信息
    • len :获取慢查询日志条目数
    • reset :重置慢查询日志

    相关配置:

    1
    2
    slowlog-log-slower-than 1000 #设置慢查询的时间下线,单位:微妙
    slowlog-max-len 100 #设置慢查询命令对应的日志显示长度,单位:命令数

Java

JDBC

概述

JDBC(Java DataBase Connectivity,Java 数据库连接)是一种用于执行 SQL 语句的 Java API,可以为多种关系型数据库提供统一访问,是由一组用 Java 语言编写的类和接口组成的。

JDBC 是 Java 官方提供的一套规范(接口),用于帮助开发人员快速实现不同关系型数据库的连接


功能类

DriverManager

DriverManager:驱动管理对象

  • 注册驱动:

    • 注册给定的驱动:public static void registerDriver(Driver driver)

    • 代码实现语法:Class.forName("com.mysql.jdbc.Driver)

    • com.mysql.jdbc.Driver 中存在静态代码块

      1
      2
      3
      4
      5
      6
      7
      static {
      try {
      DriverManager.registerDriver(new Driver());
      } catch (SQLException var1) {
      throw new RuntimeException("Can't register driver!");
      }
      }
    • 不需要通过 DriverManager 调用静态方法 registerDriver,因为 Driver 类被使用,则自动执行静态代码块完成注册驱动

    • jar 包中 META-INF 目录下存在一个 java.sql.Driver 配置文件,文件中指定了 com.mysql.jdbc.Driver

  • 获取数据库连接并返回连接对象:

    方法:public static Connection getConnection(String url, String user, String password)

    • url:指定连接的路径,语法为 jdbc:mysql://ip地址(域名):端口号/数据库名称
    • user:用户名
    • password:密码

Connection

Connection:数据库连接对象

  • 获取执行者对象
    • 获取普通执行者对象:Statement createStatement()
    • 获取预编译执行者对象:PreparedStatement prepareStatement(String sql)
  • 管理事务
    • 开启事务:setAutoCommit(boolean autoCommit),false 开启事务,true 自动提交模式(默认)
    • 提交事务:void commit()
    • 回滚事务:void rollback()
  • 释放资源
    • 释放此 Connection 对象的数据库和 JDBC 资源:void close()

Statement

Statement:执行 sql 语句的对象

  • 执行 DML 语句:int executeUpdate(String sql)
    • 返回值 int:返回影响的行数
    • 参数 sql:可以执行 insert、update、delete 语句
  • 执行 DQL 语句:ResultSet executeQuery(String sql)
    • 返回值 ResultSet:封装查询的结果
    • 参数 sql:可以执行 select 语句
  • 释放资源
    • 释放此 Statement 对象的数据库和 JDBC 资源:void close()

ResultSet

ResultSet:结果集对象,ResultSet 对象维护了一个游标,指向当前的数据行,初始在第一行

  • 判断结果集中是否有数据:boolean next()
    • 有数据返回 true,并将索引向下移动一行
    • 没有数据返回 false
  • 获取结果集中当前行的数据:XXX getXxx("列名")
    • XXX 代表数据类型(要获取某列数据,这一列的数据类型)
    • 例如:String getString(“name”); int getInt(“age”);
  • 释放资源
    • 释放 ResultSet 对象的数据库和 JDBC 资源:void close()

代码实现

数据准备

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- 创建db14数据库
CREATE DATABASE db14;

-- 使用db14数据库
USE db14;

-- 创建student表
CREATE TABLE student(
sid INT PRIMARY KEY AUTO_INCREMENT, -- 学生id
NAME VARCHAR(20), -- 学生姓名
age INT, -- 学生年龄
birthday DATE, -- 学生生日
);

-- 添加数据
INSERT INTO student VALUES (NULL,'张三',23,'1999-09-23'),(NULL,'李四',24,'1998-08-10'),
(NULL,'王五',25,'1996-06-06'),(NULL,'赵六',26,'1994-10-20');

JDBC 连接代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class JDBCDemo01 {
public static void main(String[] args) throws Exception{
//1.导入jar包
//2.注册驱动
Class.forName("com.mysql.jdbc.Driver");

//3.获取连接
Connection con = DriverManager.getConnection("jdbc:mysql://192.168.2.184:3306/db2","root","123456");

//4.获取执行者对象
Statement stat = con.createStatement();

//5.执行sql语句,并且接收结果
String sql = "SELECT * FROM user";
ResultSet rs = stat.executeQuery(sql);

//6.处理结果
while(rs.next()) {
System.out.println(rs.getInt("id") + "\t" + rs.getString("name"));
}

//7.释放资源
con.close();
stat.close();
con.close();
}
}


工具类

  • 配置文件(在 src 下创建 config.properties)

    1
    2
    3
    4
    driverClass=com.mysql.jdbc.Driver
    url=jdbc:mysql://192.168.2.184:3306/db14
    username=root
    password=123456
  • 工具类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    public class JDBCUtils {
    //1.私有构造方法
    private JDBCUtils(){
    };

    //2.声明配置信息变量
    private static String driverClass;
    private static String url;
    private static String username;
    private static String password;
    private static Connection con;

    //3.静态代码块中实现加载配置文件和注册驱动
    static{
    try{
    //通过类加载器返回配置文件的字节流
    InputStream is = JDBCUtils.class.getClassLoader().
    getResourceAsStream("config.properties");

    //创建Properties集合,加载流对象的信息
    Properties prop = new Properties();
    prop.load(is);

    //获取信息为变量赋值
    driverClass = prop.getProperty("driverClass");
    url = prop.getProperty("url");
    username = prop.getProperty("username");
    password = prop.getProperty("password");

    //注册驱动
    Class.forName(driverClass);

    } catch (Exception e) {
    e.printStackTrace();
    }
    }

    //4.获取数据库连接的方法
    public static Connection getConnection() {
    try {
    con = DriverManager.getConnection(url,username,password);
    } catch (SQLException e) {
    e.printStackTrace();
    }
    return con;
    }

    //5.释放资源的方法
    public static void close(Connection con, Statement stat, ResultSet rs) {
    if(con != null) {
    try {
    con.close();
    } catch (SQLException e) {
    e.printStackTrace();
    }
    }

    if(stat != null) {
    try {
    stat.close();
    } catch (SQLException e) {
    e.printStackTrace();
    }
    }

    if(rs != null) {
    try {
    rs.close();
    } catch (SQLException e) {
    e.printStackTrace();
    }
    }
    }
    //方法重载,可能没有返回值对象
    public static void close(Connection con, Statement stat) {
    close(con,stat,null);
    }
    }

数据封装

从数据库读取数据并封装成 Student 对象,需要:

  • Student 类成员变量对应表中的列

  • 所有的基本数据类型需要使用包装类,以防 null 值无法赋值

    1
    2
    3
    4
    5
    6
    public class Student {
    private Integer sid;
    private String name;
    private Integer age;
    private Date birthday;
    ........
  • 数据准备

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    -- 创建db14数据库
    CREATE DATABASE db14;

    -- 使用db14数据库
    USE db14;

    -- 创建student表
    CREATE TABLE student(
    sid INT PRIMARY KEY AUTO_INCREMENT, -- 学生id
    NAME VARCHAR(20), -- 学生姓名
    age INT, -- 学生年龄
    birthday DATE -- 学生生日
    );

    -- 添加数据
    INSERT INTO student VALUES (NULL,'张三',23,'1999-09-23'),(NULL,'李四',24,'1998-08-10'),(NULL,'王五',25,'1996-06-06'),(NULL,'赵六',26,'1994-10-20');
  • 操作数据库

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    public class StudentDaoImpl{
    //查询所有学生信息
    @Override
    public ArrayList<Student> findAll() {
    //1.
    ArrayList<Student> list = new ArrayList<>();
    Connection con = null;
    Statement stat = null;
    ResultSet rs = null;
    try{
    //2.获取数据库连接
    con = JDBCUtils.getConnection();

    //3.获取执行者对象
    stat = con.createStatement();

    //4.执行sql语句,并且接收返回的结果集
    String sql = "SELECT * FROM student";
    rs = stat.executeQuery(sql);

    //5.处理结果集
    while(rs.next()) {
    Integer sid = rs.getInt("sid");
    String name = rs.getString("name");
    Integer age = rs.getInt("age");
    Date birthday = rs.getDate("birthday");

    //封装Student对象
    Student stu = new Student(sid,name,age,birthday);
    //将student对象保存到集合中
    list.add(stu);
    }
    } catch(Exception e) {
    e.printStackTrace();
    } finally {
    //6.释放资源
    JDBCUtils.close(con,stat,rs);
    }
    //将集合对象返回
    return list;
    }

    //添加学生信息
    @Override
    public int insert(Student stu) {
    Connection con = null;
    Statement stat = null;
    int result = 0;
    try{
    con = JDBCUtils.getConnection();

    //3.获取执行者对象
    stat = con.createStatement();

    //4.执行sql语句,并且接收返回的结果集
    Date d = stu.getBirthday();
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
    String birthday = sdf.format(d);
    String sql = "INSERT INTO student VALUES ('"+stu.getSid()+"','"+stu.getName()+"','"+stu.getAge()+"','"+birthday+"')";
    result = stat.executeUpdate(sql);

    } catch(Exception e) {
    e.printStackTrace();
    } finally {
    //6.释放资源
    JDBCUtils.close(con,stat);
    }
    //将结果返回
    return result;
    }
    }

注入攻击

攻击演示

SQL 注入攻击演示

  • 在登录界面,输入一个错误的用户名或密码,也可以登录成功

  • 原理:我们在密码处输入的所有内容,都应该认为是密码的组成,但是 Statement 对象在执行 SQL 语句时,将一部分内容当做查询条件来执行

    1
    SELECT * FROM user WHERE loginname='aaa' AND password='aaa' OR '1'='1';

攻击解决

PreparedStatement:预编译 sql 语句的执行者对象,继承 PreparedStatement extends Statement

  • 在执行 sql 语句之前,将 sql 语句进行提前编译,明确 sql 语句的格式,剩余的内容都会认为是参数
  • sql 语句中的参数使用 ? 作为占位符

为 ? 占位符赋值的方法:setXxx(int parameterIndex, xxx data)

  • 参数1:? 的位置编号(编号从 1 开始)

  • 参数2:? 的实际参数

    1
    2
    3
    4
    String sql = "SELECT * FROM user WHERE loginname=? AND password=?";
    pst = con.prepareStatement(sql);
    pst.setString(1,loginName);
    pst.setString(2,password);

执行 sql 语句的方法

  • 执行 insert、update、delete 语句:int executeUpdate()
  • 执行 select 语句:ResultSet executeQuery()

连接池

概念

数据库连接背景:数据库连接是一种关键的、有限的、昂贵的资源,这一点在多用户的网页应用程序中体现得尤为突出。对数据库连接的管理能显著影响到整个应用程序的伸缩性和健壮性,影响到程序的性能指标。

数据库连接池:数据库连接池负责分配、管理和释放数据库连接,它允许应用程序重复使用一个现有的数据库连接,而不是再重新建立一个,这项技术能明显提高对数据库操作的性能。

数据库连接池原理


自定义池

DataSource 接口概述:

  • java.sql.DataSource 接口:数据源(数据库连接池)
  • Java 中 DataSource 是一个标准的数据源接口,官方提供的数据库连接池规范,连接池类实现该接口
  • 获取数据库连接对象:Connection getConnection()

自定义连接池:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class MyDataSource implements DataSource{
//1.定义集合容器,用于保存多个数据库连接对象
private static List<Connection> pool = Collections.synchronizedList(new ArrayList<Connection>());

//2.静态代码块,生成10个数据库连接保存到集合中
static {
for (int i = 0; i < 10; i++) {
Connection con = JDBCUtils.getConnection();
pool.add(con);
}
}
//3.返回连接池的大小
public int getSize() {
return pool.size();
}

//4.从池中返回一个数据库连接
@Override
public Connection getConnection() {
if(pool.size() > 0) {
//从池中获取数据库连接
return pool.remove(0);
}else {
throw new RuntimeException("连接数量已用尽");
}
}
}

测试连接池功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class MyDataSourceTest {
public static void main(String[] args) throws Exception{
//创建数据库连接池对象
MyDataSource dataSource = new MyDataSource();

System.out.println("使用之前连接池数量:" + dataSource.getSize());//10

//获取数据库连接对象
Connection con = dataSource.getConnection();
System.out.println(con.getClass());// JDBC4Connection

//查询学生表全部信息
String sql = "SELECT * FROM student";
PreparedStatement pst = con.prepareStatement(sql);
ResultSet rs = pst.executeQuery();

while(rs.next()) {
System.out.println(rs.getInt("sid") + "\t" + rs.getString("name") + "\t" + rs.getInt("age") + "\t" + rs.getDate("birthday"));
}

//释放资源
rs.close();
pst.close();
//目前的连接对象close方法,是直接关闭连接,而不是将连接归还池中
con.close();

System.out.println("使用之后连接池数量:" + dataSource.getSize());//9
}
}

结论:释放资源并没有把连接归还给连接池


归还连接

归还数据库连接的方式:继承方式、装饰者设计者模式、适配器设计模式、动态代理方式

继承方式

继承(无法解决)

  • 通过打印连接对象,发现 DriverManager 获取的连接实现类是 JDBC4Connection
  • 自定义一个类,继承 JDBC4Connection 这个类,重写 close() 方法
  • 查看 JDBC 工具类获取连接的方法发现:虽然自定义了一个子类,完成了归还连接的操作。但是 DriverManager 获取的还是 JDBC4Connection 这个对象,并不是我们的子类对象

代码实现

  • 自定义继承连接类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    //1.定义一个类,继承JDBC4Connection
    public class MyConnection1 extends JDBC4Connection{
    //2.定义Connection连接对象和容器对象的成员变量
    private Connection con;
    private List<Connection> pool;

    //3.通过有参构造方法为成员变量赋值
    public MyConnection1(String hostToConnectTo, int portToConnectTo, Properties info, String databaseToConnectTo, String url,Connection con,List<Connection> pool) throws SQLException {
    super(hostToConnectTo, portToConnectTo, info, databaseToConnectTo, url);
    this.con = con;
    this.pool = pool;
    }

    //4.重写close方法,完成归还连接
    @Override
    public void close() throws SQLException {
    pool.add(con);
    }
    }
  • 自定义连接池类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    //将之前的连接对象换成自定义的子类对象
    private static MyConnection1 con;

    //4.获取数据库连接的方法
    public static Connection getConnection() {
    try {
    //等效于:MyConnection1 con = new JDBC4Connection(); 语法错误!
    con = DriverManager.getConnection(url,username,password);
    } catch (SQLException e) {
    e.printStackTrace();
    }

    return con;
    }

装饰者

自定义类实现 Connection 接口,通过装饰设计模式,实现和 mysql 驱动包中的 Connection 实现类相同的功能

在实现类对每个获取的 Connection 进行装饰:把连接和连接池参数传递进行包装

特点:通过装饰设计模式连接类我们发现,有很多需要重写的方法,代码太繁琐

  • 装饰设计模式类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    //1.定义一个类,实现Connection接口
    public class MyConnection2 implements Connection {
    //2.定义Connection连接对象和连接池容器对象的变量
    private Connection con;
    private List<Connection> pool;

    //3.提供有参构造方法,接收连接对象和连接池对象,对变量赋值
    public MyConnection2(Connection con,List<Connection> pool) {
    this.con = con;
    this.pool = pool;
    }

    //4.在close()方法中,完成连接的归还
    @Override
    public void close() throws SQLException {
    pool.add(con);
    }
    //5.剩余方法,只需要调用mysql驱动包的连接对象完成即可
    @Override
    public Statement createStatement() throws SQLException {
    return con.createStatement();
    }
    ..........
    }
  • 自定义连接池类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    @Override
    public Connection getConnection() {
    if(pool.size() > 0) {
    //从池中获取数据库连接
    Connection con = pool.remove(0);
    //通过自定义连接对象进行包装
    MyConnection2 mycon = new MyConnection2(con,pool);
    //返回包装后的连接对象
    return mycon;
    }else {
    throw new RuntimeException("连接数量已用尽");
    }
    }

适配器

使用适配器设计模式改进,提供一个适配器类,实现 Connection 接口,将所有功能进行实现(除了 close 方法),自定义连接类只需要继承这个适配器类,重写需要改进的 close() 方法即可。

特点:自定义连接类中很简洁。剩余所有的方法抽取到了适配器类中,但是适配器这个类还是我们自己编写。

  • 适配器类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public abstract class MyAdapter implements Connection {

    // 定义数据库连接对象的变量
    private Connection con;

    // 通过构造方法赋值
    public MyAdapter(Connection con) {
    this.con = con;
    }

    // 所有的方法,均调用mysql的连接对象实现
    @Override
    public Statement createStatement() throws SQLException {
    return con.createStatement();
    }
    }
  • 自定义连接类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    public class MyConnection3 extends MyAdapter {
    //2.定义Connection连接对象和连接池容器对象的变量
    private Connection con;
    private List<Connection> pool;

    //3.提供有参构造方法,接收连接对象和连接池对象,对变量赋值
    public MyConnection3(Connection con,List<Connection> pool) {
    super(con); // 将接收的数据库连接对象给适配器父类传递
    this.con = con;
    this.pool = pool;
    }

    //4.在close()方法中,完成连接的归还
    @Override
    public void close() throws SQLException {
    pool.add(con);
    }
    }
  • 自定义连接池类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    //从池中返回一个数据库连接
    @Override
    public Connection getConnection() {
    if(pool.size() > 0) {
    //从池中获取数据库连接
    Connection con = pool.remove(0);
    //通过自定义连接对象进行包装
    MyConnection3 mycon = new MyConnection3(con,pool);
    //返回包装后的连接对象
    return mycon;
    }else {
    throw new RuntimeException("连接数量已用尽");
    }
    }

动态代理

使用动态代理的方式来改进

自定义数据库连接池类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class MyDataSource implements DataSource {
//1.准备一个容器。用于保存多个数据库连接对象
private static List<Connection> pool = Collections.synchronizedList(new ArrayList<>());

//2.定义静态代码块,获取多个连接对象保存到容器中
static{
for(int i = 1; i <= 10; i++) {
Connection con = JDBCUtils.getConnection();
pool.add(con);
}
}
//3.提供一个获取连接池大小的方法
public int getSize() {
return pool.size();
}

//动态代理方式
@Override
public Connection getConnection() throws SQLException {
if(pool.size() > 0) {
Connection con = pool.remove(0);

Connection proxyCon = (Connection) Proxy.newProxyInstance(
con.getClass().getClassLoader(), new Class[]{Connection.class},
new InvocationHandler() {
/*
执行Connection实现类连接对象所有的方法都会经过invoke
如果是close方法,归还连接
如果不是,直接执行连接对象原有的功能即可
*/
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if(method.getName().equals("close")) {
//归还连接
pool.add(con);
return null;
}else {
return method.invoke(con,args);
}
}
});
return proxyCon;
}else {
throw new RuntimeException("连接数量已用尽");
}
}
}

开源项目

C3P0

使用 C3P0 连接池:

  • 配置文件名称:c3p0-config.xml,必须放在 src 目录下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    <c3p0-config>
    <!-- 使用默认的配置读取连接池对象 -->
    <default-config>
    <!-- 连接参数 -->
    <property name="driverClass">com.mysql.jdbc.Driver</property>
    <property name="jdbcUrl">jdbc:mysql://192.168.2.184:3306/db14</property>
    <property name="user">root</property>
    <property name="password">123456</property>

    <!-- 连接池参数 -->
    <!--初始化数量-->
    <property name="initialPoolSize">5</property>
    <!--最大连接数量-->
    <property name="maxPoolSize">10</property>
    <!--超时时间 3000ms-->
    <property name="checkoutTimeout">3000</property>
    </default-config>

    <named-config name="otherc3p0">
    <!-- 连接参数 -->
    <!-- 连接池参数 -->
    </named-config>
    </c3p0-config>
  • 代码演示

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    public class C3P0Test1 {
    public static void main(String[] args) throws Exception{
    //1.创建c3p0的数据库连接池对象
    DataSource dataSource = new ComboPooledDataSource();

    //2.通过连接池对象获取数据库连接
    Connection con = dataSource.getConnection();

    //3.执行操作
    String sql = "SELECT * FROM student";
    PreparedStatement pst = con.prepareStatement(sql);

    //4.执行sql语句,接收结果集
    ResultSet rs = pst.executeQuery();

    //5.处理结果集
    while(rs.next()) {
    System.out.println(rs.getInt("sid") + "\t" + rs.getString("name") + "\t" + rs.getInt("age") + "\t" + rs.getDate("birthday"));
    }

    //6.释放资源
    rs.close(); pst.close(); con.close();
    }
    }

Druid

Druid 连接池:

  • 配置文件:druid.properties,必须放在 src 目录下

    1
    2
    3
    4
    5
    6
    7
    driverClassName=com.mysql.jdbc.Driver
    url=jdbc:mysql://192.168.2.184:3306/db14
    username=root
    password=123456
    initialSize=5
    maxActive=10
    maxWait=3000
  • 代码演示

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    public class DruidTest1 {
    public static void main(String[] args) throws Exception{
    //获取配置文件的流对象
    InputStream is = DruidTest1.class.getClassLoader().getResourceAsStream("druid.properties");

    //1.通过Properties集合,加载配置文件
    Properties prop = new Properties();
    prop.load(is);

    //2.通过Druid连接池工厂类获取数据库连接池对象
    DataSource dataSource = DruidDataSourceFactory.createDataSource(prop);

    //3.通过连接池对象获取数据库连接进行使用
    Connection con = dataSource.getConnection();

    //4.执行sql语句,接收结果集
    String sql = "SELECT * FROM student";
    PreparedStatement pst = con.prepareStatement(sql);
    ResultSet rs = pst.executeQuery();

    //5.处理结果集
    while(rs.next()) {
    System.out.println(rs.getInt("sid") + "\t" + rs.getString("name") + "\t" + rs.getInt("age") + "\t" + rs.getDate("birthday"));
    }

    //6.释放资源
    rs.close(); pst.close(); con.close();
    }
    }


工具类

数据库连接池的工具类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
public class DataSourceUtils {
//1.私有构造方法
private DataSourceUtils(){}

//2.声明数据源变量
private static DataSource dataSource;

//3.提供静态代码块,完成配置文件的加载和获取数据库连接池对象
static{
try{
//完成配置文件的加载
InputStream is = DataSourceUtils.class.getClassLoader().getResourceAsStream("druid.properties");
Properties prop = new Properties();
prop.load(is);

//获取数据库连接池对象
dataSource = DruidDataSourceFactory.createDataSource(prop);
} catch (Exception e) {
e.printStackTrace();
}
}

//4.提供一个获取数据库连接的方法
public static Connection getConnection() {
Connection con = null;
try {
con = dataSource.getConnection();
} catch (SQLException e) {
e.printStackTrace();
}
return con;
}

//5.提供一个获取数据库连接池对象的方法
public static DataSource getDataSource() {
return dataSource;
}

//6.释放资源
public static void close(Connection con, Statement stat, ResultSet rs) {
if(con != null) {
try {
con.close();
} catch (SQLException e) {
e.printStackTrace();
}
}

if(stat != null) {
try {
stat.close();
} catch (SQLException e) {
e.printStackTrace();
}
}

if(rs != null) {
try {
rs.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
//方法重载
public static void close(Connection con, Statement stat) {
if(con != null) {
try {
con.close();
} catch (SQLException e) {
e.printStackTrace();
}
}

if(stat != null) {
try {
stat.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}


Jedis

基本使用

Jedis 用于 Java 语言连接 Redis 服务,并提供对应的操作 API

  • jar 包导入

    下载地址:https://mvnrepository.com/artifact/redis.clients/jedis

    基于 maven:

    1
    2
    3
    4
    5
    <dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.9.0</version>
    </dependency>
  • 客户端连接 Redis:API 文档 http://xetorthio.github.io/jedis/

    连接 redis:Jedis jedis = new Jedis("192.168.0.185", 6379)

    操作 redis:jedis.set("name", "seazean"); jedis.get("name")

    关闭 redis:jedis.close()

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class JedisTest {
public static void main(String[] args) {
//1.获取连接对象
Jedis jedis = new Jedis("192.168.2.185",6379);
//2.执行操作
jedis.set("age","39");
String hello = jedis.get("hello");
System.out.println(hello);
jedis.lpush("list1","a","b","c","d");
List<String> list1 = jedis.lrange("list1", 0, -1);
for (String s:list1 ) {
System.out.println(s);
}
jedis.sadd("set1","abc","abc","def","poi","cba");
Long len = jedis.scard("set1");
System.out.println(len);
//3.关闭连接
jedis.close();
}
}

工具类

连接池对象:

  • JedisPool:Jedis 提供的连接池技术
  • poolConfig:连接池配置对象
  • host:Redis 服务地址
  • port:Redis 服务端口号

JedisPool 的构造器如下:

1
2
3
public JedisPool(GenericObjectPoolConfig poolConfig, String host, int port) {
this(poolConfig, host, port, 2000, (String)null, 0, (String)null);
}
  • 创建配置文件 redis.properties

    1
    2
    3
    4
    redis.maxTotal=50
    redis.maxIdel=10
    redis.host=192.168.2.185
    redis.port=6379
  • 工具类:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    public class JedisUtils {
    private static int maxTotal;
    private static int maxIdel;
    private static String host;
    private static int port;
    private static JedisPoolConfig jpc;
    private static JedisPool jp;

    static {
    ResourceBundle bundle = ResourceBundle.getBundle("redis");
    //最大连接数
    maxTotal = Integer.parseInt(bundle.getString("redis.maxTotal"));
    //活动连接数
    maxIdel = Integer.parseInt(bundle.getString("redis.maxIdel"));
    host = bundle.getString("redis.host");
    port = Integer.parseInt(bundle.getString("redis.port"));

    //Jedis连接配置
    jpc = new JedisPoolConfig();
    jpc.setMaxTotal(maxTotal);
    jpc.setMaxIdle(maxIdel);
    //连接池对象
    jp = new JedisPool(jpc, host, port);
    }

    //对外访问接口,提供jedis连接对象,连接从连接池获取
    public static Jedis getJedis() {
    return jp.getResource();
    }
    }

1
2
3
4
5
title: Tool
date: 2022-01-01 00:00:00
tags: Tool
categories: Tool
comment

Git

Git概述

版本系统

SVN 是集中式版本控制系统,版本库是集中放在中央服务器的,而开发人员工作的时候,用的都是自己的电脑,所以首先要从中央服务器下载最新的版本,然后开发,开发完后,需要把自己开发的代码提交到中央服务器。

集中式版本控制工具缺点:服务器单点故障、容错性差

Git 是分布式版本控制系统(Distributed Version Control System,简称 DVCS) ,分为两种类型的仓库:

本地仓库和远程仓库:

  • 本地仓库:是在开发人员自己电脑上的 Git 仓库
  • 远程仓库:是在远程服务器上的 Git 仓库

工作流程

1.从远程仓库中克隆代码到本地仓库

2.从本地仓库中 checkout 代码然后进行代码修改

3.在提交前先将代码提交到暂存区

4.提交到本地仓库。本地仓库中保存修改的各个历史版本

5.修改完成后,需要和团队成员共享代码时,将代码push到远程仓库

Git安装

下载地址: https://git-scm.com/download

代码托管

Git 中存在两种类型的仓库,即本地仓库和远程仓库。那么我们如何搭建Git远程仓库呢?我们可以借助互联网上提供的一些代码托管服务来实现,其中比较常用的有 GitHub、码云、GitLab 等。

GitHub(地址:https://github.com/)是一个面向开源及私有软件项目的托管平台,因为只支持 Git 作为唯一的版本库格式进行托管,故名 GitHub

码云(地址: https://gitee.com/)是国内的一个代码托管平台,由于服务器在国内,所以相比于 GitHub,码云速度会更快

GitLab(地址: https://about.gitlab.com/ )是一个用于仓库管理系统的开源项目,使用 Git 作为代码管理工具,并在此基础上搭建起来的 web 服务


环境配置

安装 Git 后首先要设置用户名称和 email 地址,因为每次 Git 提交都会使用该用户信息,此信息和注册的代码托管平台的信息无关

设置用户信息:

  • git config –global user.name “Seazean”
  • git config –global user.email “zhyzhyang@sina.com” //用户名和邮箱可以随意填写,不会校对

查看配置信息:

  • git config –list
  • git config user.name

通过上面的命令设置的信息会保存在用户目录下 /.gitconfig 文件中


本地仓库

获取仓库

  • 本地仓库初始化

    1. 在电脑的任意位置创建一个空目录(例如 repo1)作为本地 Git 仓库

    2. 进入这个目录中,点击右键打开 Git bash 窗口

    3. 执行命令 git init

      如果在当前目录中看到 .git 文件夹(此文件夹为隐藏文件夹)则说明 Git 仓库创建成功

  • 远程仓库克隆
    通过 Git 提供的命令从远程仓库进行克隆,将远程仓库克隆到本地

    命令:git clone 远程 Git 仓库地址(HTTPS 或者 SSH)

  • 生成 SSH 公钥步骤

    • 设置账户
    • cd ~/.ssh(查看是否生成过 SSH 公钥)user 目录下
    • 生成 SSH 公钥:ssh-keygen -t rsa -C "email"
      • -t 指定密钥类型,默认是 rsa ,可以省略
      • -C 设置注释文字,比如邮箱
      • -f 指定密钥文件存储文件名
    • 查看命令: cat ~/.ssh/id_rsa.pub
    • 公钥测试命令: ssh -T git@github.com

工作过程

版本库:.git 隐藏文件夹就是版本库,版本库中存储了很多配置信息、日志信息和文件版本信息等

工作目录(工作区):包含 .git 文件夹的目录就是工作目录,主要用于存放开发的代码

暂存区:.git 文件夹中有很多文件,其中有一个 index 文件就是暂存区,也可以叫做 stage,暂存区是一个临时保存修改文件的地方


文件操作

常用命令

命令 作用
git status 查看 git 状态 (文件是否进行了添加、提交操作)
git add filename 添加,将指定文件添加到暂存区
git commit -m ‘message’ 提交,将暂存区文件提交到本地仓库,删除暂存区的该文件
git commit –amend 修改 commit 的 message
git rm filename 删除,删除工作区的文件,不是仓库,需要提交
git mv filename 移动或重命名工作区文件
git reset filename 使用当前分支上的修改覆盖暂存区,将暂存区的文件取消暂存
git checkout filename 使用暂存区的修改覆盖工作目录,用来撤销本次修改(危险)
git log 查看日志( git 提交的历史日志)
git reflog 可以查看所有分支的所有操作记录(包括已经被删除的 commit 记录的操作)

其他指令:可以跳过暂存区域直接从分支中取出修改,或者直接提交修改到分支中

  • git commit -a 直接把所有文件的修改添加到暂存区然后执行提交
  • git checkout HEAD – files 取出最后一次修改,可以用来进行回滚操作

文件状态

  • Git 工作目录下的文件存在两种状态:

    • untracked 未跟踪(未被纳入版本控制)
    • tracked 已跟踪(被纳入版本控制)
      • Unmodified 未修改状态
      • Modified 已修改状态
      • Staged 已暂存状态
  • 查看文件状态:文件的状态会随着我们执行 Git 的命令发生变化

    • git status 查看文件状态
    • git status –s 查看更简洁的文件状态

文件忽略

一般我们总会有些文件无需纳入Git 的管理,也不希望它们总出现在未跟踪文件列表。 通常都是些自动生成的文件,比如日志文件,或者编译过程中创建的临时文件等。 在这种情况下,我们可以在工作目录中创建一个名为 .gitignore 的文件(文件名称固定),列出要忽略的文件模式。下面是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
# no .a files
*.a
# but do track lib.a, even though you're ignoring .a files above
!lib.a
# only ignore the TODO file in the current directory, not subdir/TODO
/TODO
# ignore all files in the build/ directory
build/
# ignore doc/notes.txt, but not doc/server/arch.txt
doc/*.txt
# ignore all .pdf files in the doc/ directory
doc/**/*.pdf

远程仓库

工作流程

Git 有四个工作空间的概念,分别为 工作空间、暂存区、本地仓库、远程仓库。

pull = fetch + merge

fetch 是从远程仓库更新到本地仓库,pull是从远程仓库直接更新到工作空间中


查看仓库

git remote:显示所有远程仓库的简写

git remote -v:显示所有远程仓库

git remote show :显示某个远程仓库的详细信息

添加仓库

git remote add :添加一个新的远程仓库,并指定一个可以引用的简写

克隆仓库

git clone (HTTPS or SSH):克隆远程仓库

Git 克隆的是该 Git 仓库服务器上的几乎所有数据(包括日志信息、历史记录等),而不仅仅是复制工作所需要的文件,当你执行 git clone 命令的时候,默认配置下远程 Git 仓库中的每一个文件的每一个版本都将被拉取下来。

删除仓库

git remote rm :移除远程仓库,从本地移除远程仓库的记录,并不会影响到远程仓库

拉取仓库

git fetch :从远程仓库获取最新版本到本地仓库,不会自动 merge

git pull :从远程仓库获取最新版本并 merge 到本地仓库

注意:如果当前本地仓库不是从远程仓库克隆,而是本地创建的仓库,并且仓库中存在文件,此时再从远程仓库拉取文件的时候会报错(fatal: refusing to merge unrelated histories ),解决此问题可以在 git pull 命令后加入参数 –allow-unrelated-histories

推送仓库

git push :上传本地指定分支到远程仓库


版本管理

命令:git reset –hard 版本唯一索引值


分支管理

查看分支

git branch:列出所有本地分支

git branch -r:列出所有远程分支

git branch -a:列出所有本地分支和远程分支

创建分支

git branch branch-name:新建一个分支,但依然停留在当前分支

git checkout -b branch-name:新建一个分支,并切换到该分支

推送分支

git push origin branch-name:推送到远程仓库,origin 是引用名

切换分支

git checkout branch-name:切换到 branch-name 分支

合并分支

git merge branch-name:合并指定分支到当前分支

有时候合并操作不会如此顺利。 如果你在两个不同的分支中,对同一个文件的同一个部分进行了不同的修改,Git 就没办法合并它们,同时会提示文件冲突。此时需要我们打开冲突的文件并修复冲突内容,最后执行 git add 命令来标识冲突已解决

删除分支

git branch -d branch-name:删除分支

git push origin –d branch-name:删除远程仓库中的分支 (origin 是引用名)

如果要删除的分支中进行了开发动作,此时执行删除命令并不会删除分支,如果坚持要删除此分支,可以将命令中的 -d 参数改为 -D:git branch -D branch-name


标签管理

查看标签

git tag:列出所有 tag

git show tag-name:查看 tag 详细信息

标签作用:在开发的一些关键时期,使用标签来记录这些关键时刻,保存快照,例如发布版本、有重大修改、升级的时候、会使用标签记录这些时刻,来永久标记项目中的关键历史时刻

新建标签

git tag tag-name:新建标签,如(git tag v1.0.1)

推送标签

git push [remotename] [tagname]:推送到远程仓库

git push [remotename] –tags:推送所有的标签

切换标签

git checkout tag-name:切换标签

删除标签

git tag -d tag-name:删除本地标签

git push origin :refs/tags/ tag-name:删除远程标签


IDEA操作

环境配置

File → Settings 打开设置窗口,找到 Version Control 下的 git 选项

选择 git 的安装目录后可以点击 Test 按钮测试是否正确配置:D:\Program Files\Git\cmd\git.exe

创建仓库

1、VCS → Import into Version Control → Create Git Repository

2、选择工程所在的目录,这样就创建好本地仓库了

3、点击git后边的对勾,将当前项目代码提交到本地仓库

​ 注意: 项目中的配置文件不需要提交到本地仓库中,提交时,忽略掉即可

文件操作

右键项目名打开菜单 Git → Add → commit

版本管理

  • 版本对比

  • 版本切换方式一:控制台 Version Control → Log → 右键 Reset Current Branch → Reset,这种切换会抛弃原来的提交记录

  • 版本切换方式二:控制台 Version Control → Log → Revert Commit → Merge → 处理代码 → commit,这种切换会当成一个新的提交记录,之前的提交记录也都保留


分支管理

  • 创建分支:VCS → Git → Branches → New Branch → 给分支起名字 → ok
  • 切换分支:idea 右下角 Git → 选择要切换的分支 → checkout
  • 合并分支:VCS → Git → Merge changes → 选择要合并的分支 → merge
  • 删除分支:idea 右下角 → 选中要删除的分支 → Delete

推送仓库

  1. VCS → Git → Push → 点击 master Define remote
  2. 将远程仓库的 url 路径复制过来 → Push

克隆仓库

File → Close Project → Checkout from Version Control → Git → 指定远程仓库的路径 → 指定本地存放的路径 → clone


Linux

操作系统

操作系统(Operation System),是管理计算机硬件与软件资源的计算机程序,同时也是计算机系统的内核与基石。操作系统需要处理管理与配置内存、决定系统资源供需的优先次序、控制输入设备与输出设备、操作网络与管理文件系统等基本事务,操作系统也提供一个让用户与系统交互的操作界面

操作系统作为接口的示意图:

移动设备操作系统:


Linux系统

系统介绍

从内到位依次是硬件 → 内核层 → Shell 层 → 应用层 → 用户
Linux

内核层:核心和基础,附着在硬件平台上,控制和管理系统内的各种资源,有效的组织进程的运行,扩展硬件的功能,提高资源利用效率,为用户提供安全可靠的应用环境。

Shell 层:与用户直接交互的界面。用户可以在提示符下输入命令行,由 Shell 解释执行并输出相应结果或者有关信息,所以我们也把 Shell 称作命令解释器,利用系统提供的丰富命令可以快捷而简便地完成许多工作。


文件系统

Linux 文件系统目录结构和熟知的 windows 系统有较大区别,没有各种盘符的概念。根目录只有一个/,采用层级式的树状目录结构。

Linux文件系统


远程连接

设置IP

NAT

首先设置虚拟机中 NAT 模式的选项,打开 VMware,点击编辑下的虚拟网络编辑器,设置 NAT 参数

注意:VMware Network Adapter VMnet8 保证是启用状态

静态IP

在普通用户下不能修改网卡的配置信息;所以我们要切换到 root 用户进行 ip 配置:su root/su

  • 修改网卡配置文件:vim /etc/sysconfig/network-scripts/ifcfg-ens33

  • 修改文件内容

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    TYPE=Ethernet
    PROXY_METHOD=none
    BROWSER_ONLY=no
    BOOTPROTO=static
    IPADDR=10.2.111.62
    NETMASK=255.255.252.0
    GATEWAY=10.2.111.254
    DEFROUTE=yes
    IPV4_FAILURE_FATAL=no
    IPV6INIT=yes
    IPV6_AUTOCONF=yes
    IPV6_DEFROUTE=yes
    IPV6_FAILURE_FATAL=no
    IPV6_ADDR_GEN_MODE=stable-privacy
    NAME=ens33
    UUID=2c2371f1-ef29-4514-a568-c4904bd11c82
    DEVICE=ens33
    ONBOOT=true
    ###########################
    BOOTPROTO设置为静态static
    IPADDR设置ip地址
    NETMASK设置子网掩码
    GATEWAY设置网关
    ONBOOT设置为true在系统启动时是否激活网卡
    执行保存 :wq!
  • 重启网络:systemctl restart network

  • 查看IP:ifconfig

  • 宿主机 ping 虚拟机,虚拟机 ping 宿主机

  • 在虚拟机中访问网络,需要增加一块 NAT 网卡

    • 【虚拟机】–【设置】–【添加】

远程登陆

服务器维护工作 都是在 远程 通过 SSH 客户端 来完成的, 并没有图形界面, 所有的维护工作都需要通过命令来完成,Linux 服务器需要安装 SSH 相关服务

首先执行 sudo apt-get install openssh-server 指令,接下来用 xshell 连接

先用普通用户登录,然后转成 root


用户管理

Linux 系统是一个多用户、多任务的操作系统。多用户是指在 Linux 操作系统中可以创建多个用户,而这些多用户又可以同时执行各自不同的任务,而互不影响

在 Linux 系统中,会存在着以下几个概念:

  • 用户名:用户的名称
  • 用户所属的组:当前用户所属的组
  • 用户的家目录:当前账号登录成功之后的目录,就叫做该用户的家目录

用户管理

当前用户

logname:用于显示目前用户的名称

  • –help:在线帮助

  • –vesion:显示版本信息

切换用户

su UserName:切换用户

su -c comman root:切换用户为 root 并在执行 comman 指令后退出返回原使用者

su:切换到 root 用户

用户添加

命令:useradd [options] 用户名

参数说明:

  • -c comment 指定一段注释性描述
  • -d 指定用户主目录,如果此目录不存在,则同时使用 -m 选项,可以创建主目录
  • -m 创建用户的主目录
  • -g 用户组,指定用户所属的用户组
  • -G 用户组,用户组 指定用户所属的附加组
  • -s Shell 文件 指定用户的登录 Shell
  • -u 用户号,指定用户的用户号,如果同时有 -o 选项,则可以重复使用其他用户的标识号。

如何知道添加用户成功呢? 通过指令 cat /etc/passwd 查看

1
2
seazean:x:  1000:1000:Seazean:/home/seazean:/bin/bash
用户名 密码 用户ID 组ID 注释 家目录 shell程序

useradd -m Username 新建用户成功之后,会建立 home 目录,但是此时有问题没有指定 shell 的版本,不是我们熟知的 bash,功能上有很多限制,进行 sudo useradd -m -s /bin/bash Username

用户密码

系统安装好默认的 root 用户是没有密码的,需要给 root 设置一个密码 sudo passwd root.

  • 普通用户:sudo passwd UserName

  • 管理员用户:passwd [options] UserName

    • -l:锁定密码,即禁用账号
    • -u:密码解锁
    • -d:使账号无密码
    • -f:强迫用户下次登录时修改密码

用户权限

usermod 命令通过修改系统帐户文件来修改用户账户信息

修改用户账号就是根据实际情况更改用户的有关属性,如用户号、主目录、用户组、登录 Shell 等

  • 普通用户:sudo usermod [options] Username

  • 管理员用户:usermod [options] Username

    • usermod -l newName Username
    • -l 新的登录名称

用户删除

删除用户账号就是要将 /etc/passwd 等系统文件中的该用户记录删除,必要时还删除用户的主目录

  • 普通用户:sudo userdel [options] Username

  • 管理员用户:userdel [options] Username

    • -f:强制删除用户,即使用户当前已登录
    • -r:删除用户的同时,删除与用户相关的所有文件

用户组管理

组管理

添加组:groupadd 组名

创建用户的时加入组:useradd -m -g 组名 用户名

添加用户组

新增一个用户组(组名可见名知意,符合规范即可),然后将用户添加到组中,需要使用管理员权限

命令:groupadd [options] Groupname

  • -g GID 指定新用户组的组标识号(GID)
  • -o 一般与 -g 选项同时使用,表示新用户组的 GID 可以与系统已有用户组的 GID 相同

新增用户组 Seazean:groupadd Seazean

修改用户组

需要使用管理员权限

命令:groupmod [options] Groupname

  • -g GID 为用户组指定新的组标识号。
  • -o 与 -g 选项同时使用,用户组的新 GID 可以与系统已有用户组的 GID 相同
  • -n 新用户组 将用户组的名字改为新名字

修改 Seazean 组名为 zhy:groupmod -n zhy Seazean

删除用户组

  • 普通用户:sudo groupdel Groupname

  • 管理员用户:groupdel Groupname

    • -f 用户的主组也继续删除
    • -h 显示帮助信息

用户所属组

查询用户所属组:groups Username

查看用户及组信息:id Username

创建用户的时加入组:useradd -m -g Groupname Username

修改用户所属组:usermod -g Groupname Username

usermod常用选项:

  • -d 用户的新主目录
  • -l 新的登录名称

gpasswd

gpasswd 是 Linux 工作组文件 /etc/group 和 /etc/gshadow 管理工具,用于将一个用户添加到组或从组中删除

命令:gpasswd 选项 Username Groupname

  • -a 向组 GROUP 中添加用户 USER
  • -d 从组 GROUP 中添加或删除用户

查看用户组下所有用户(所有用户):grep ‘Groupname’ /etc/group


系统管理

man

在控制台输入:命令名 -h/ -help/ –h /空

可以看到命令的帮助文档

man [指令名称]:查看帮助文档,比如 man ls,退出方式 q


date

date 可以用来显示或设定系统的日期与时间

命令:date [options]

  • -d<字符串>:显示字符串所指的日期与时间,字符串前后必须加上双引号;

  • -s<字符串>:根据字符串来设置日期与时间,字符串前后必须加上双引号

  • -u:显示 GMT

  • –version:显示版本信息

查看时间:date → 2020年 11月 30日 星期一 17:10:54 CST

查看指定格式时间:date “+%Y-%m-%d %H:%M:%S” → 2020-11-30 17:11:44

设置日期指令:date -s “2019-12-23 19:21:00”


id

id 会显示用户以及所属群组的实际与有效 ID,若两个 ID 相同则仅显示实际 ID;若仅指定用户名称,则显示目前用户的 ID

命令:id [-gGnru] [–help] [–version] [用户名称] //参数的顺序

  • -g 或–group:显示用户所属群组的 ID
  • -G 或–groups:显示用户所属附加群组的 ID
  • -n 或–name:显示用户,所属群组或附加群组的名称。
  • -r 或–real:显示实际 ID
  • -u 或–user:显示用户 ID

id 命令参数虽然很多,但是常用的是不带参数的 id 命令,主要看 uid 和组信息


sudo

sudo:控制用户对系统命令的使用权限,通过 sudo 可以提高普通用户的操作权限

  • -V 显示版本编号
  • -h 会显示版本编号及指令的使用方式说明
  • -l 显示出自己(执行 sudo 的使用者)的权限
  • -command 要以系统管理者身份(或以 -u 更改为其他人)执行的指令

sudo -u root command -l:指定 root 用户执行指令 command


top

top:用于实时显示 process 的动态

  • -c:command 属性进行了命令补全

  • -p 进程号:显示指定 pid 的进程信息

  • -d 秒数:表示进程界面更新时间(每几秒刷新一次)

  • -H 表示线程模式

top -Hp 进程 id:分析该进程内各线程的 CPU 使用情况

各进程(任务)的状态监控属性解释说明:

  • PID — 进程 id
  • TID — 线程 id
  • USER — 进程所有者
  • PR — 进程优先级
  • NI — nice 值,负值表示高优先级,正值表示低优先级
  • VIRT — 进程使用的虚拟内存总量,单位 kb,VIRT=SWAP+RES
  • RES — 进程使用的、未被换出的物理内存大小,单位 kb,RES=CODE+DATA
  • SHR — 共享内存大小,单位 kb
  • S — 进程状态,D=不可中断的睡眠状态 R=运行 S=睡眠 T=跟踪/停止 Z=僵尸进程
  • %CPU — 上次更新到现在的 CPU 时间占用百分比
  • %MEM — 进程使用的物理内存百分比
  • TIME+ — 进程使用的 CPU 时间总计,单位 1/100 秒
  • COMMAND — 进程名称(命令名/命令行)

ps

Linux 系统中查看进程使用情况的命令是 ps 指令

命令:ps

  • -e: 显示所有进程
  • -f: 全格式
  • a: 显示终端上的所有进程
  • u: 以用户的格式来显示进程信息
  • x: 显示后台运行的进程
  • -T:开启线程查看
  • -p:指定线程号

一般常用格式为 ps -ef 或者 ps aux 两种。显示的信息大体一致,略有区别:

  • 如果想查看进程的 CPU 占用率和内存占用率,可以使用 aux
  • 如果想查看进程的父进程 ID 和完整的 COMMAND 命令,可以使用 ef

ps -T -p <pid>:显示某个进程的线程

ps 和 top 区别:

  • ps 命令:可以查看进程的瞬间信息,是系统在过去执行的进程的静态快照

  • top 命令:可以持续的监视进程的动态信息


kill

Linux kill 命令用于删除执行中的程序或工作,并不是让进程直接停止,而是给进程发一个信号,可以进入终止逻辑

命令:kill [-s <信息名称或编号>] [程序] 或 kill [-l <信息编号>]

  • -l <信息编号>:若不加<信息编号>选项,则-l参数会列出全部的信息名称
  • -s <信息名称或编号>:指定要送出的信息
  • -KILL:强制杀死进程
  • -9:彻底杀死进程(常用)
  • [程序] 程序的 PID、PGID、工作编号

kill 15642 . kill -KILL 15642. kill -9 15642

杀死指定用户所有进程:

  1. 过滤出 user 用户进程 :kill -9 $(ps -ef | grep user)

  2. 直接杀死:kill -u user


shutdown

shutdown 命令可以用来进行关闭系统,并且在关机以前传送讯息给所有使用者正在执行的程序,shutdown 也可以用来重开机

普通用户:sudo shutdown [-t seconds] [-rkhncfF] time [message]

管理员用户:shutdown [-t seconds] [-rkhncfF] time [message]

  • -t seconds:设定在几秒钟之后进行关机程序
  • -k:并不会真的关机,只是将警告讯息传送给所有使用者
  • -r:关机后重新开机
  • -h:关机后停机
  • -n:不采用正常程序来关机,用强迫的方式杀掉所有执行中的程序后自行关机
  • -c:取消目前已经进行中的关机动作
  • -f:关机时,不做 fcsk 动作(检查 Linux 档系统)
  • -F:关机时,强迫进行 fsck 动作
  • time:设定关机的时间
  • message:传送给所有使用者的警告讯息

立即关机:shutdown -h now 或者 shudown now

指定 1 分钟后关机并显示警告信息:shutdown +1 "System will shutdown after 1 minutes"

指定 1 分钟后重启并发出警告信息:shutdown –r +1 "1分钟后关机重启"


reboot

reboot 命令用于用来重新启动计算机

命令:reboot [-n] [-w] [-d] [-f] [-i]

  • -n:在重开机前不做将记忆体资料写回硬盘的动作
  • -w:并不会真的重开机,只是把记录写到 /var/log/wtmp 档案里
  • -d:不把记录写到 /var/log/wtmp 档案里(-n 这个参数包含了 -d)
  • -f:强迫重开机,不呼叫 shutdown 这个指令
  • -i:在重开机之前先把所有网络相关的装置先停止

who

who 命令用于显示系统中有哪些使用者正在上面,显示的资料包含了使用者 ID、使用的终端机、上线时间、CPU 使用量、动作等等

命令:who - [husfV] [user]

  • -H 或 –heading:显示各栏位的标题信息列(常用 who -H
  • -i 或 -u 或 –idle:显示闲置时间,若该用户在前一分钟之内有进行任何动作,将标示成 . 号,如果该用户已超过 24 小时没有任何动作,则标示出 old 字符串
  • -m:此参数的效果和指定 am i 字符串相同
  • -q 或–count:只显示登入系统的帐号名称和总人数
  • -s:此参数将忽略不予处理,仅负责解决who指令其他版本的兼容性问题
  • -w 或-T或–mesg或–message或–writable:显示用户的信息状态栏
  • –help:在线帮助
  • –version:显示版本信息

systemctl

命令:systemctl [command] [unit]

  • –version 查看版本号

  • start:立刻启动后面接的 unit

  • stop:立刻关闭后面接的 unit

  • restart:立刻关闭后启动后面接的 unit,亦即执行 stop 再 start 的意思

  • reload:不关闭 unit 的情况下,重新载入配置文件,让设置生效

  • status:目前后面接的这个 unit 的状态,会列出有没有正在执行、开机时是否启动等信息

  • enable:设置下次开机时,后面接的 unit 会被启动

  • disable:设置下次开机时,后面接的 unit 不会被启动

  • is-active:目前有没有正在运行中

  • is-enable:开机时有没有默认要启用这个 unit

  • kill :不要被 kill 这个名字吓着了,它其实是向运行 unit 的进程发送信号

  • show:列出 unit 的配置

  • mask:注销 unit,注销后你就无法启动这个 unit 了

  • unmask:取消对 unit 的注销


timedatectl

timedatectl用于控制系统时间和日期。可以查询和更改系统时钟于设定,同时可以设定和修改时区信息。在实际开发过程中,系统时间的显示会和实际出现不同步;我们为了校正服务器时间、时区会使用timedatectl命令

timedatectl:显示系统的时间信息

timedatectl status:显示系统的当前时间和日期

timedatectl | grep Time:查看当前时区

timedatectl list-timezones:查看所有可用的时区

timedatectl set-timezone “Asia/Shanghai”:设置本地时区为上海

timedatectl set-ntp true/false:启用/禁用时间同步

timedatectl set-time “2020-12-20 20:45:00”:时间同步关闭后可以设定时间

NTP 即 Network Time Protocol(网络时间协议),是一个互联网协议,用于同步计算机之间的系统时钟,timedatectl 实用程序可以自动同步你的Linux系统时钟到使用NTP的远程服务器


clear

clear 命令用于清除屏幕

通过执行 clear 命令,就可以把缓冲区的命令全部清理干净


exit

exit 命令用于退出目前的 shell

执行 exit 可使 shell 以指定的状态值退出。若不设置状态值参数,则 shell 以预设值退出。状态值 0 代表执行成功,其他值代表执行失败;exit 也可用在 script,离开正在执行的 script,回到 shell

命令:exit [状态值]

  • 0 表示成功(Zero - Success)

  • 非 0 表示失败(Non-Zero - Failure)

  • 2 表示用法不当(Incorrect Usage)

  • 127 表示命令没有找到(Command Not Found)

  • 126 表示不是可执行的(Not an executable)

  • 大于等于 128 信号产生


文件管理

常用命令

ls

ls命令相当于我们在Windows系统中打开磁盘、或者打开文件夹看到的目录以及文件的明细。

命令:ls [options] 目录名称

  • -a :全部的文件,连同隐藏档( 开头为 . 的文件) 一起列出来(常用)
  • -d :仅列出目录本身,而不是列出目录内的文件数据(常用)
  • -l :显示不隐藏的文件与文件夹的详细信息;(常用)
  • ls -al = ll 命令:显示所有文件与文件夹的详细信息

pwd

pwd 是 Print Working Directory 的缩写,也就是显示目前所在当前目录的命令

命令:pwd 选项

  • -L 打印 $PWD 变量的值,如果它包含了当前的工作目录
  • -P 打印当前的物理路径,不带有任何的符号链接

cd

cd 是 Change Directory 的缩写,这是用来变换工作目录的命令

命令:cd [相对路径或绝对路径]

  • cd ~ :表示回到根目录
  • cd .. :返回上级目录
  • 相对路径 在输入路径时, 最前面不是以 / 开始的 , 表示相对当前目录所在的目录位置
    • 例如: /usr/share/doc
  • 绝对路径 在输入路径时, 最前面是以 / 开始的, 表示从根目录开始的具体目录位置
    • 由 /usr/share/doc 到 /usr/share/man 时,可以写成: cd ../man
    • 优点:定位准确, 不会因为 工作目录变化 而变化

mkdir

mkdir命令用于建立名称为 dirName 之子目录

命令:mkdir [-p] dirName

  • -p 确保目录名称存在,不存在的就建一个,用来创建多级目录。

mkdir -p aaa/bbb:在 aaa 目录下,创建一个 bbb 的子目录。 若 aaa 目录原本不存在,则建立一个

rmdir

rmdir命令删除空的目录

命令:rmdir [-p] dirName

  • -p 是当子目录被删除后使它也成为空目录的话,则顺便一并删除

rmdir -p aaa/bbb:在 aaa 目录中,删除名为 bbb 的子目录。若 bbb 删除后,aaa 目录成为空目录,则 aaa 同时也会被删除

cp

cp 命令主要用于复制文件或目录

命令:cp [options] source… directory

  • -a:此选项通常在复制目录时使用,它保留链接、文件属性,并复制目录下的所有内容。其作用等于dpR参数组合
  • -d:复制时保留链接。这里所说的链接相当于Windows系统中的快捷方式
  • -f:覆盖已经存在的目标文件而不给出提示
  • -i:与 -f 选项相反,在覆盖目标文件之前给出提示,要求用户确认是否覆盖,回答 y 时目标文件将被覆盖
  • -p:除复制文件的内容外,还把修改时间和访问权限也复制到新文件中
  • -r/R:若给出的源文件是一个目录文件,此时将复制该目录下所有的子目录和文件
  • -l:不复制文件,只是生成链接文件

cp –r aaa/* ccc:复制 aaa 下的所有文件到 ccc,不加参数 -r 或者 -R,只复制文件,而略过目录

rm

rm命令用于删除一个文件或者目录。

命令:rm [options] name…

  • -i 删除前逐一询问确认。
  • -f 即使原档案属性设为唯读,亦直接删除,无需逐一确认
  • -r 将目录及以下之档案亦逐一删除,递归删除

注:文件一旦通过 rm 命令删除,则无法恢复,所以必须格外小心地使用该命令

mv

mv 命令用来为文件或目录改名、或将文件或目录移入其它位置

1
2
mv [options] source dest
mv [options] source... directory
  • -i:若指定目录已有同名文件,则先询问是否覆盖旧文件

  • -f:在 mv 操作要覆盖某已有的目标文件时不给任何指示

    命令格式 运行结果
    mv 文件名 文件名 将源文件名改为目标文件名
    mv 文件名 目录名 将文件移动到目标目录
    mv 目录名 目录名 目标目录已存在,将源目录移动到目标目录。目标目录不存在则改名
    mv 目录名 文件名 出错

文件属性

基本属性

Linux 系统是一种典型的多用户系统,不同的用户处于不同的地位,拥有不同的权限。为了保护系统的安全性,Linux系统对不同的用户访问同一文件(包括目录文件)的权限做了不同的规定

在Linux中第一个字符代表这个文件是目录、文件或链接文件等等。

  • 当为 d 则是目录
  • 当为 - 则是文件
  • 若是 l 则表示为链接文档 link file
  • 若是 b 则表示为装置文件里面的可供储存的接口设备(可随机存取装置)
  • 若是 c 则表示为装置文件里面的串行端口设备,例如键盘、鼠标(一次性读取装置)

接下来的字符,以三个为一组,均为[rwx] 的三个参数组合。其中,[ r ]代表可读(read)、[ w ]代表可写(write)、[ x ]代表可执行(execute)。 要注意的是,这三个权限的位置不会改变,如果没有权限,就会出现[ - ]。

从左至右用 0-9 这些数字来表示:

  • 第 0 位确定文件类型
  • 第 1-3 位确定属主拥有该文件的权限
  • 第 4-6 位确定属组拥有该文件的权限
  • 第 7-9 位确定其他用户拥有该文件的权限

文件信息

对于一个文件,都有一个特定的所有者,也就是对该文件具有所有权的用户(属主);还有这个文件是属于哪个组的(属组)

  • 文件的【属主】有一套【读写执行权限rwx】
  • 文件的【属组】有一套【读写执行权限rwx】

ls -l 可以查看文件夹下文件的详细信息, 从左到右 依次是:

  • 权限(A 区域): 第一个字符如果是 d 表示目录
  • 硬链接数(B 区域):通俗的讲就是有多少种方式, 可以访问当前目录和文件
  • 属主(C 区域):文件是所有者、或是叫做属主
  • 属组(D 区域): 文件属于哪个组
  • 大小(E 区域):文件大小
  • 时间(F 区域):最后一次访问时间
  • 名称(G 区域):文件的名称

更改权限

权限概述

Linux 文件属性有两种设置方法,一种是数字,一种是符号

Linux 的文件调用权限分为三级 : 文件属主、属组、其他,利用 chmod 可以控制文件如何被他人所调用。

1
2
chmod [-cfvR] [--help] [--version] mode file...
mode : 权限设定字串,格式: [ugoa...][[+-=][rwxX]...][,...]
  • u 表示档案的拥有者,g 表示与该档案拥有者属于同一个 group 者,o 表示其他的人,a 表示这三者皆是

  • +表示增加权限、- 表示取消权限、= 表示唯一设定权限

  • r 表示可读取,w 表示可写入,x 表示可执行,X 表示只有该档案是个子目录或者该档案已经被设定过为可执行

数字权限

命令:chmod [-R] xyz 文件或目录

  • xyz : 就是刚刚提到的数字类型的权限属性,为 rwx 属性数值的相加
  • -R : 进行递归(recursive)的持续变更,亦即连同次目录下的所有文件都会变更

文件的权限字符为:[-rwxrwxrwx], 这九个权限是三三一组的,我们使用数字来代表各个权限

各权限的数字对照表:[r]:4、[w]:2、[x]:1、[-]:0

每种身份(owner/group/others)的三个权限(r/w/x)分数是需要累加的,例如权限为:[-rwxrwx—] 分数是

  • owner = rwx = 4+2+1 = 7
  • group = rwx = 4+2+1 = 7
  • others= — = 0+0+0 = 0

表示为:chmod -R 770 文件名

符号权限

  • user 属主权限
  • group 属组权限
  • others 其他权限
  • all 全部的身份

我们就可以使用 u g o a 来代表身份的权限,读写的权限可以写成 r w x

chmod u=rwx,g=rx,o=r a.txt:将as.txt的权限设置为 -rwxr-xr–

chmod a-r a.txt:将文件的所有权限去除 r


更改属组

chgrp 命令用于变更文件或目录的所属群组

文件或目录权限的的拥有者由所属群组来管理,可以使用 chgrp 指令去变更文件与目录的所属群组

1
2
chgrp [-cfhRv][--help][--version][所属群组][文件或目录...]
chgrp [-cfhRv][--help][--reference=<参考文件或目录>][--version][文件或目录...]

chgrp -v root aaa:将文件 aaa 的属组更改成 root(其他也可以)


更改属主

利用 chown 可以将档案的拥有者加以改变。

使用权限 : 管理员账户

1
2
chown [–R] 属主名 文件名
chown [-R] 属主名:属组名 文件名

chown root aaa:将文件aaa的属主更改成root

chown seazean:seazean aaa:将文件aaa的属主和属组更改为seazean


文件操作

touch

touch 命令用于创建文件、修改文件或者目录的时间属性,包括存取时间和更改时间。若文件不存在,系统会建立一个新的文件

1
touch [-acfm][-d<日期时间>][-r<参考文件或目录>] [-t<日期时间>][--help][--version][文件或目录…]
  • -a 改变档案的读取时间记录
  • -m 改变档案的修改时间记录
  • -c 假如目的档案不存在,不会建立新的档案。与 –no-create 的效果一样
  • -f 不使用,是为了与其他 unix 系统的相容性而保留
  • -r 使用参考档的时间记录,与 –file 的效果一样
  • -d 设定时间与日期,可以使用各种不同的格式
  • -t 设定档案的时间记录,格式与 date 指令相同
  • –no-create 不会建立新档案
  • –help 列出指令格式
  • –version 列出版本讯息

touch t.txt:创建 t.txt 文件

touch t{1..10}.txt:创建10 个名为 t1.txt 到 t10.txt 的空文件

touch t.txt:更改 t.txt 的访问时间为现在

stat

stat 命令用于显示 inode 内容

命令:stat [文件或目录]

cat

cat 是一个文本文件查看和连接工具,用于小文件

命令:cat [-AbeEnstTuv] [–help] [–version] Filename

  • -n 显示文件加上行号
  • -b 和 -n 相似,只不过对于空白行不编号

less

less 用于查看文件,但是 less 在查看之前不会加载整个文件,用于大文件

命令:less [options] Filename

  • -N 显示每行行号

tail

tail 命令可用于查看文件的内容,有一个常用的参数 -f 常用于查阅正在改变的日志文件

命令:tail [options] Filename

  • -f 循环读取,动态显示文档的最后内容
  • -n 显示文件的尾部 n 行内容
  • -c 显示字节数
  • -nf 查看最后几行日志信息

tail -f filename:动态显示最尾部的内容

tail -n +2 txtfile.txt:显示文件 txtfile.txt 的内容,从第 2 行至文件末尾

tail -n 2 txtfile.txt:显示文件 txtfile.txt 的内容,最后 2 行

head 命令可用于查看文件的开头部分的内容,有一个常用的参数 -n 用于显示行数,默认为 10

  • -q 隐藏文件名
  • -v 显示文件名
  • -c 显示的字节数
  • -n 显示的行数

head -n Filename:查看文件的前一部分

head -n 20 Filename:查看文件的前 20 行

grep

grep 指令用于查找内容包含指定的范本样式的文件,若不指定任何文件名称,或是所给予的文件名为 -,则 grep 指令会从标准输入设备读取数据

1
grep [-abcEFGhHilLnqrsvVwxy][-A<显示列数>][-B<显示列数>][-C<显示列数>][-d<进行动作>][-e<范本样式>][-f<范本文件>][--help][范本样式][文件或目录...]
  • -c 只输出匹配行的计数
  • -i 不区分大小写
  • -h 查询多文件时不显示文件名
  • -l 查询多文件时只输出包含匹配字符的文件名
  • -n 显示匹配行及行号
  • -s 不显示不存在或无匹配文本的错误信息
  • -v 显示不包含匹配文本的所有行
  • –color=auto 可以将找到的关键词部分加上颜色的显示

**管道符 |**:表示将前一个命令处理的结果传递给后面的命令处理

  • grep aaaa Filename :显示存在关键字 aaaa 的行
  • grep -n aaaa Filename:显示存在关键字 aaaa 的行,且显示行号
  • grep -i aaaa Filename:忽略大小写,显示存在关键字 aaaa 的行
  • grep -v aaaa Filename:显示存在关键字 aaaa 的所有行
  • ps -ef | grep sshd:查找包含 sshd 进程的进程信息
  • ps -ef | grep -c sshd:查找 sshd 相关的进程个数

echo

将字符串输出到控制台 , 通常和重定向联合使用

命令:echo string,如果字符串有空格, 为了避免歧义 请增加 双引号 或者 单引号

  • 通过 命令 > 文件 将命令的成功结果覆盖指定文件内容
  • 通过 命令 >> 文件 将命令的成功结果追加指定文件的后面
  • 通过 命令 &>> 文件 将 命令的失败结果追加指定文件的后面

echo "程序员" >> a.txt:将程序员追加到 a.txt 后面

cat 不存在的目录 &>> error.log:将错误信息追加到 error.log 文件

awk

AWK 是一种处理文本文件的语言,是一个强大的文本分析工具

1
2
awk [options] 'script' var=value file(s)
awk [options] -f scriptfile var=value file(s)
  • -F fs:指定输入文件折分隔符,fs 是一个字符串或者是一个正则表达式

  • -v:var=value 赋值一个用户定义变量

  • -f:从脚本文件中读取 awk 命令

  • $n:获取第几段内容

  • $0:获取当前行 内容

  • NF:表示当前行共有多少个字段

  • $NF:代表最后一个字段

  • $(NF-1):代表倒数第二个字段

  • NR:代表处理的是第几行

    1
    2
    3
    命令:awk 'BEGIN{初始化操作}{每行都执行} END{结束时操作}'   
    文件名BEGIN{ 这里面放的是执行前的语句 }{这里面放的是处理每一行时要执行的语句}
    END {这里面放的是处理完所有的行后要执行的语句 }
1
2
3
4
5
6
7
//准备数据
zhangsan 68 99 26
lisi 98 66 96
wangwu 38 33 86
zhaoliu 78 44 36
maq 88 22 66
zhouba 98 44 46
  • cat a.txt | awk '/zhang|li/':搜索含有 zhang 和 li 的学生成绩

  • awk "/zhang|li/" a.txt :同上一个命令,效果一样

    1
    2
    3
    zhangsan 68 99 26
    lisi 98 66 96
    zhaoliu 78 44 36
  • cat a.txt | awk -F ' ' '{print $1,$2,$3}':按照空格分割,打印 一二三列内容

  • awk -F ' ' '{OFS="\t"}{print $1,$2,$3}':按照制表符 tab 进行分割,打印一二三列
    \b:退格 \f:换页 \n:换行 \r:回车 \t:制表符

    1
    2
    3
    4
    5
    6
    zhangsan	68	99
    lisi 98 66
    wangwu 38 33
    zhaoliu 78 44
    maq 88 22
    zhouba 98 44
  • awk -F ',' '{print toupper($1)}' a.txt:根据逗号分割,打印内容,第一段大写

    函数名 含义 作用
    toupper() upper 字符 转成 大写
    tolower() lower 字符 转成小写
    length() length 返回 字符长度
  • awk -F ' ' 'BEGIN{}{total=total+$4} END{print total}' a.txt:计算的是第4列的总分

  • awk -F ' ' 'BEGIN{}{total=total+$4} END{print total, NR}' a.txt :查看总分, 总人数

  • awk -F ' ' 'BEGIN{}{total=total+$4} END{print total, NR, (total/NR)}' a.txt:查看总分, 总人数,平均数

  • cat a.txt | awk -F ' ' 'BEGIN{}{total=total+$4} END{print total}' :可以这样写

find

find 命令用来在指定目录下查找文件,如果使用该命令不设置任何参数,将在当前目录下查找子目录与文件,并且将查找到的子目录和文件全部进行显示

命令:find <指定目录> <指定条件> <指定内容>

  • find . -name "*.gz":将目前目录及其子目录下所有延伸档名是 gz 的文件查询出来
  • find . -ctime -1:将目前目录及其子目录下所有最近 1 天内更新过的文件查询出来
  • find / -name 'seazean':全局搜索 seazean

read

read 命令用于从标准输入读取数值

1
read [-ers] [-a aname] [-d delim] [-i text] [-n nchars] [-N nchars] [-p prompt] [-t timeout] [-u fd] [name ...]

sort

Linux sort 命令用于将文本文件内容加以排序

1
sort [-bcdfimMnr][文件]
  • -n 依照数值的大小排序
  • -r 以相反的顺序来排序(sort 默认的排序方式是升序,改成降序,加 -r)
  • -u 去掉重复

面试题:一列数字,输出最大的 4 个不重复的数

1
2
sort -ur a.txt | head -n 4
sort -r a.txt | uniq | head -n 4

uniq

uniq 用于重复数据处理,使用前先 sort 排序

1
uniq [OPTION]... [INPUT [OUTPUT]]
  • -c 在数据行前出现的次数
  • -d 只打印重复的行,重复的行只显示一次
  • -D 只打印重复的行,重复的行出现多少次就显示多少次
  • -f 忽略行首的几个字段
  • -i 忽略大小写
  • -s 忽略行首的几个字母
  • -u 只打印唯一的行
  • -w 比较不超过 n 个字母

文件压缩

tar

tar 的主要功能是打包、压缩和解压文件,tar 本身不具有压缩功能,是调用压缩功能实现的。

命令:tar [必要参数] [选择参数] [文件]

  • -c 产生 .tar 文件
  • -v 显示详细信息
  • -z 打包同时压缩
  • -f 指定压缩后的文件名
  • -x 解压 .tar 文件
  • -t 列出 tar 文件中包含的文件的信息
  • -r 附加新的文件到tar文件中

tar -cvf txt.tar txtfile.txt :将 txtfile.txt 文件打包(仅打包,不压缩)

tar -zcvf combine.tar.gz 1.txt 2.txt 3.txt:将 123.txt 文件打包压缩(gzip)

tar -ztvf txt.tar.gz:查看 tar 中有哪些文件

tar -zxvf Filename -C 目标路径:解压

gzip

gzip命令用于压缩文件。

gzip是个使用广泛的压缩程序,文件经它压缩过后,其名称后面会多出”.gz”的扩展名

  • gzip * :压缩目录下的所有文件,删除源文件。不支持直接压缩目录
  • gzip -rv 目录名:递归压缩目录
  • gzip -dv *:解压文件并列出详细信息

gunzip

gunzip命令用于解压文件。用于解开被gzip压缩过的文件

命令:gunzip [options] [文件或者目录]

gunzip 001.gz :解压001.gz文件

zip

zip 命令用于压缩文件。

zip 是个使用广泛的压缩程序,文件经它压缩后会另外产生具有 .zip 扩展名的压缩文件

命令:zip [必要参数] [选择参数] [文件]

  • -q 不显示指令执行过程
  • -r 递归处理,将指定目录下的所有文件和子目录一并处理

zip -q -r z.zip *:将该目录的文件全部压缩

unzip

unzip 命令用于解压缩 zip 文件,unzip 为 .zip 压缩文件的解压缩程序

命令:unzip [必要参数] [选择参数] [文件]

  • -l 查看压缩文件内所包含的文件

  • -d<目录> 指定文件解压缩后所要存储的目录。

unzip -l z.zip :查看压缩文件中包含的文件

unzip -d ./unFiles z.zip:把文件解压到指定的目录下

bzip2

bzip2 命令是 .bz2 文件的压缩程序。

bzip2 采用新的压缩演算法,压缩效果比传统的 LZ77/LZ78 压缩演算法好,若不加任何参数,bzip2 压缩完文件后会产生 .bz2 的压缩文件,并删除原始的文件

1
bzip2 [-cdfhkLstvVz][--repetitive-best][--repetitive-fast][- 压缩等级][要压缩的文件]

压缩:bzip2 a.txt

bunzip2

bunzip2 命令是 .bz2 文件的解压缩程序。

命令:bunzip2 [-fkLsvV] [.bz2压缩文件]

  • -v 解压缩文件时,显示详细的信息。

解压:bunzip2 -v a.bz2


文件编辑

Vim

vim:是从 vi 发展出来的一个文本编辑器

  • 命令模式:在 Linux 终端中输入vim 文件名 就进入了命令模式,但不能输入文字
  • 编辑模式:在命令模式下按 i 就会进入编辑模式,此时可以写入程式,按 Esc 可回到命令模式
  • 末行模式:在命令模式下按 : 进入末行模式,左下角会有一个冒号,可以敲入命令并执行

打开文件

Ubuntu 默认没有安装 vim,需要先安装 vim,安装命令:sudo apt-get install vim

Vim 有三种模式:命令模式(Command mode)、插入模式(Insert mode)、末行模式(Last Line mode)

Vim 使用的选项 说明 常用
vim filename 打开或新建一个文件,将光标置于第一行首部 常用
vim -r filename 恢复上次vim打开时崩溃的文件
vim -R filename 把指定的文件以只读的方式放入Vim编辑器
vim + filename 打开文件,将光标置于最后一行的首部 常用
vim +n filename 打开文件,将光标置于n行的首部 常用
vim +/pattern filename 打开文件,将光标置于第一个与pattern匹配的位置
vim -c command filename 对文件编辑前,先执行指定的命令

插入模式

在命令模式下,通过按下 i、I、a、A、o、O 这 6 个字母进入插入模式

快捷键 功能描述
i 在光标所在位置插入文本,光标后的文本向右移动
I 在光标所在行的行首插入文本,行首是该行的第一个非空白字符
o 在光标所在行的下面插入新的一行,光标停在空行首
O 在光标所在行的上面插入新的一行,光标停在空行首
a 在光标所在位置之后插入文本
A 在光标所在行的行尾插入文本

按下 ESC 键,离开插入模式,进入命令模式

因为我们是一个空文件,所以使用【I】或者【i】都可以

如果里面的文本很多,要使用【A】进入编辑模式,即在行末添加文本


命令模式

Vim 打开一个文件(文件可以存在,也可以不存在),默认进入命令模式。在该模式下, 输入的字符会被当做指令,而不会被当做要输入的文字

移动光标
快捷键 功能描述
w 光标移动至下一个单词的单词首
b 光标移动至上一个单词的单词首
e 光标移动至下一个单词的单词尾
0 光标移动至当前行的行首
^ 行首, 第一个不是空白字符的位置
$ 光标移动至当前行的行尾
gg 光标移动至文件开头
G 光标移动至文件末尾
ngg 光标移动至第n行
nG 光标移动至第n行
:n 光标移动至第n行

选中文本

在 vi/vim 中要选择文本,需要显示 visual 命令切换到可视模式

vi/vim 中提供了三种可视模式,方便程序员的选择选中文本的方式

按 ESC 可以放弃选中, 返回到命令模式

命令 模式 功能
v 可视模式 从光标位置开始按照正常模式选择文本
V 可视化模式 选中光标经过的完整行
Ctrl + v 可是块模式 垂直方向选中文本
撤销删除

在学习编辑命令之前,先要知道怎样撤销之前一次错误的编辑操作

命令 英文 功能
u undo 撤销上次的命令(ctrl + z)
Ctrl + r uredo 恢复撤销的命令

删除的内容此时并没有真正的被删除,在剪切板中,按下 p 键,可以将删除的内容粘贴回来

快捷键 功能描述
x 删除光标所在位置的字符
d 删除移动命令对应的内容
dd 删除光标所在行的内容
D 删除光标位置到行尾的内容
:n1,n2 删除从 a1 到 a2 行的文本内容

删除命令可以和移动命令连用, 以下是常见的组合命令(扩展):

命令 作用
dw 删除从光标位置到单词末尾
d} 删除从光标位置到段落末尾
dG 删除光标所行到文件末尾的所有内容
ndd 删除当前行(包括此行)到后 n 行内容

复制粘贴

vim 中提供有一个 被复制文本的缓冲区

  • 复制命令会将选中的文字保存在缓冲区
  • 删除命令删除的文字会被保存在缓冲区
  • 在需要的位置,使用粘贴命令可以将缓冲对的文字插入到光标所在的位置
  • vim 中的文本缓冲区只有一个,如果后续做过复制、剪切操作,之前缓冲区中的内容会被替换
快捷键 功能描述
y 复制已选中的文本到剪切板
yy 将光标所在行复制到剪切板
nyy 复制从光标所在行到向下n行
p 将剪切板中的内容粘贴到光标后
P 将剪切板中的内容粘贴到光标前

注意:vim 中的文本缓冲区和系统的剪切板不是同一个,在其他软件中使用 Ctrl + C 复制的内容,不能在 vim 中通过 p 命令粘贴,可以在编辑模式下使用鼠标右键粘贴


查找替换

查找

快捷键 功能描述
/abc 从光标所在位置向后查找字符串 abc
/^abc 查找以 abc 为行首的行
/abc$ 查找以 abc 为行尾的行
?abc 从光标所在位置向前查找字符串 abc
* 向后查找当前光标所在单词
# 向前查找当前光标所在单词
n 查找下一个,向同一方向重复上次的查找指令
N 查找上一个,向相反方向重复上次的查找指令

替换:

命令 功能 工作模式
r 替换当前字符 命令模式
R 替换当前行光标后的字符 替换模式
  • 光标选中要替换的字符
  • R 命令可以进入替换模式,替换完成后,按下 ESC 可以回到命令模式
  • 替换命令的作用就是不用进入编辑模式,对文件进行轻量级的修改

末行模式

在命令模式下,按下 : 键进入末行模式

命令 功能描述
:wq 保存并退出 Vim 编辑器
:wq! 保存并强制退出 Vim 编辑器
:q 不保存且退出 Vim 编辑器
:q! 不保存且强制退出 Vim 编辑器
:w 保存但是不退出 Vim 编辑器
:w! 强制保存但是不退出 Vim 编辑器
:w filename 另存到 filename 文件
x! 保存文本,退出保存但是不退出 Vim 编辑器,更通用的命令
ZZ 直接退出保存但是不退出 Vim 编辑器
:n 光标移动至第 n 行行首

异常处理

  • 如果 vim 异常退出, 在磁盘上可能会保存有 交换文件

  • 下次再使用 vim 编辑文件时,会看到以下屏幕信息:

  • ls -a 一下,会看到隐藏的 .swp 文件,删除了此文件即可


链接

1
ln [-sf] source_filename dist_filename
  • -s:默认是实体链接,加 -s 为符号链接
  • -f:如果目标文件存在时,先删除目标文件

实体链接

  • 在目录下创建一个条目,记录着文件名与 inode 编号,这个 inode 就是源文件的 inode
  • 删除任意一个条目,文件还是存在,只要引用数量不为 0
  • 不能跨越文件系统、不能对目录进行链接
1
2
3
4
ln /etc/crontab .
ll
34474855 -rw-r--r--. 2 root root 451 Jun 10 2014 crontab
34474855 -rw-r--r--. 2 root root 451 Jun 10 2014 /etc/crontab

符号链接

  • 符号链接文件保存着源文件所在的绝对路径,在读取时会定位到源文件上,可以理解为 Windows 的快捷方式

  • 当源文件被删除了,链接文件就打不开了

  • 记录的是路径,所以可以为目录建立符号链接

    1
    2
    34474855 -rw-r--r--. 2 root root 451 Jun 10 2014 /etc/crontab
    53745909 lrwxrwxrwx. 1 root root 12 Jun 23 22:31 /root/crontab2 -> /etc/crontab

进程管理

查看进程

ps 指令:查看某个时间点的进程信息

top 指令:实时显示进程信息

pstree:查看进程树

1
pstree -A	#查看所有进程树

进程 ID

进程号:

  • 进程号为 0 的进程通常是调度进程,常常被称为交换进程(swapper),该进程是内核的一部分,它并不执行任何磁盘上的程序,因此也被称为系统进程

  • 进程号为 1 是 init 进程,是一个守护进程,在自举过程结束时由内核调用,init 进程绝不会终止,是一个普通的用户进程,但是它以超级用户特权运行

父进程 ID 为 0 的进程通常是内核进程,作为系统自举过程的一部分而启动,init 进程是个例外,它的父进程是 0,但它是用户进程

  • 主存 = RAM + BIOS 部分的 ROM
  • DISK:存放 OS 和 Bootloader
  • BIOS:基于 I/O 处理系统
  • Bootloader:加载 OS,将 OS 放入内存

自举程序存储在内存中 ROM,用来加载操作系统,初始化 CPU、寄存器、内存等。CPU 的程序计数器指自举程序第一条指令,当计算机通电,CPU 开始读取并执行自举程序,将操作系统(不是全部,只是启动计算机的那部分程序)装入 RAM 中,这个过程是自举过程。装入完成后程序计数器设置为 RAM 中操作系统的第一条指令,接下来 CPU 将开始执行(启动)操作系统的指令

存储在 ROM 中保留很小的自举装入程序,完整功能的自举程序保存在磁盘的启动块上,启动块位于磁盘的固定位,拥有启动分区的磁盘称为启动磁盘或系统磁盘(C 盘)


进程状态

状态 说明
R running or runnable (on run queue) 正在执行或者可执行,此时进程位于执行队列中
D uninterruptible sleep (usually I/O) 不可中断阻塞,通常为 IO 阻塞
S interruptible sleep (waiting for an event to complete) 可中断阻塞,此时进程正在等待某个事件完成
Z zombie (terminated but not reaped by its parent) 僵死,进程已经终止但是尚未被其父进程获取信息
T stopped (either by a job control signal or because it is being traced) 结束,进程既可以被作业控制信号结束,也可能是正在被追踪

孤儿进程:

  • 一个父进程退出,而它的一个或多个子进程还在运行,那么这些子进程将成为孤儿进程
  • 孤儿进程将被 init 进程所收养,并由 init 进程对它们完成状态收集工作,所以孤儿进程不会对系统造成危害

僵尸进程:

  • 一个子进程的进程描述符在子进程退出时不会释放,只有当父进程通过 wait() 或 waitpid() 获取了子进程信息后才会释放。如果子进程退出,而父进程并没有调用 wait() 或 waitpid(),那么子进程的进程描述符仍然保存在系统中,这种进程称之为僵尸进程
  • 僵尸进程通过 ps 命令显示出来的状态为 Z(zombie)
  • 系统所能使用的进程号是有限的,产生大量僵尸进程,会导致系统没有可用的进程号而不能产生新的进程
  • 要消灭系统中大量的僵尸进程,只需要将其父进程杀死,此时僵尸进程就会变成孤儿进程,从而被 init 进程所收养,这样 init 进程就会释放所有的僵尸进程所占有的资源,从而结束僵尸进程

补充:

  • 守护进程(daemon)是一类在后台运行的特殊进程,用于执行特定的系统任务。
  • 守护进程是脱离于终端并且在后台运行的进程,脱离终端是为了避免在执行的过程中的信息在终端上显示,并且进程也不会被任何终端所产生的终端信息所打断
  • 很多守护进程在系统引导的时候启动,并且一直运行直到系统关闭;另一些只在需要的时候才启动,完成任务后就自动结束

状态改变

SIGCHLD

当一个子进程改变了它的状态时(停止运行,继续运行或者退出),有两件事会发生在父进程中:

  • 得到 SIGCHLD 信号
  • waitpid() 或者 wait() 调用会返回

子进程发送的 SIGCHLD 信号包含了子进程的信息,比如进程 ID、进程状态、进程使用 CPU 的时间等;在子进程退出时进程描述符不会立即释放,父进程通过 wait() 和 waitpid() 来获得一个已经退出的子进程的信息,释放子进程的 PCB


wait

1
pid_t wait(int *status)

参数:status 用来保存被收集的子进程退出时的状态,如果不关心子进程如何销毁,可以设置这个参数为 NULL

父进程调用 wait() 会一直阻塞,直到收到一个子进程退出的 SIGCHLD 信号,wait() 函数就会销毁子进程并返回

  • 成功,返回被收集的子进程的进程 ID
  • 失败,返回 -1,同时 errno 被置为 ECHILD(如果调用进程没有子进程,调用就会失败)

waitpid

1
pid_t waitpid(pid_t pid, int *status, int options)

作用和 wait() 完全相同,只是多了两个可控制的参数 pid 和 options

  • pid:指示一个子进程的 ID,表示只关心这个子进程退出的 SIGCHLD 信号;如果 pid=-1 时,那么和 wait() 作用相同,都是关注所有子进程退出的 SIGCHLD 信号
  • options:主要有 WNOHANG 和 WUNTRACED 两个,WNOHANG 可以使 waitpid() 调用变成非阻塞的,就是会立即返回,父进程可以继续执行其它任务

网络管理

network

  • 启动:service network start

  • 停止:service network stop

  • 重启:service network restart


ifconfig

ifconfig 是 Linux 中用于显示或配置网络设备的命令,英文全称是 network interfaces configuring

ifconfig 命令用于显示或设置网络设备。ifconfig 可设置网络设备的状态,或是显示目前的设置

1
ifconfig [网络设备][down up -allmulti -arp -promisc][add<地址>][del<地址>][<hw<网络设备类型><硬件地址>][io_addr<I/O地址>][irq<IRQ地址>][media<网络媒介类型>][mem_start<内存地址>][metric<数目>][mtu<字节>][netmask<子网掩码>][tunnel<地址>][-broadcast<地址>][-pointopoint<地址>][IP地址]
  • ifconfig:显示激活的网卡信息 ens

    ens33(或 eth0)表示第一块网卡,IP地址是 192.168.0.137,广播地址 broadcast 192.168.0.255,掩码地址netmask 255.255.255.0 ,inet6 对应的是 ipv6

    lo 是表示主机的回坏地址,用来测试一个网络程序,但又不想让局域网或外网的用户能够查看,只能在此台主机上运行和查看所用的网络接口

  • ifconfig ens33 down:关闭网卡

  • ifconfig ens33 up:启用网卡


ping

ping 命令用于检测主机

执行 ping 指令会使用 ICMP 传输协议,发出要求回应的信息,若远端主机的网络功能没有问题,就会回应该信息

1
ping [-dfnqrRv][-c<完成次数>][-i<间隔秒数>][-I<网络界面>][-l<前置载入>][-p<范本样式>][-s<数据包大小>][-t<存活数值>][主机名称或IP地址]
  • -c<完成次数>:设置完成要求回应的次数;

  • ping -c 2 www.baidu.com

    icmp_seq:ping 序列,从1开始

    ttl:IP 生存时间值

    time:响应时间,数值越小,联通速度越快


netstat

netstat 命令用于显示网络状态

1
netstat [-acCeFghilMnNoprstuvVwx][-A<网络类型>][--ip]
  • -a 显示所有连线中的 Socket,显示详细的连接状况
  • -i 显示网络界面信息表单,显示网卡列表
  • -p 显示正在使用 Socket 的程序识别码和程序名称
  • -n 显示使用 IP 地址,而不通过域名服务器
  • -t 显示 TCP 传输协议的连线状况。
  • -u 显示 UDP 传输协议的连线状况
  • -aptn:查看所有 TCP 开启端口
  • -apun:查看所有 UDP 开启端口

补充:

  • netstat -apn | grep port:查看指定端口号
  • lsof -i:port :查看指定端口号

磁盘管理

挂载概念

在安装 Linux 系统时设立的各个分区,如根分区、/boot 分区等都是自动挂载的,也就是说不需要人为操作,开机就会自动挂载。但是光盘、U 盘等存储设备如果需要使用,就必须人为的进行挂载

在 Windows 下插入 U 盘也是需要挂载(分配盘符)的,只不过 Windows 下分配盘符是自动的。其实挂载可以理解为 Windows 当中的分配盘符,只不过 Windows 当中是以英文字母 ABCD 等作为盘符,而 Linux 是拿系统目录作为盘符,当然 Linux 当中也不叫盘符,而是称为挂载点,而把为分区或者光盘等存储设备分配一个挂载点的过程称为挂载

Linux 中的根目录以外的文件要想被访问,需要将其关联到根目录下的某个目录来实现,这种关联操作就是挂载,这个目录就是挂载点,解除次关联关系的过程称之为卸载

挂载点的目录需要以下几个要求:

  • 目录要先存在,可以用 mkdir 命令新建目录
  • 挂载点目录不可被其他进程使用到
  • 挂载点下原有文件将被隐藏

lsblk

lsblk 命令的英文是 list block,即用于列出所有可用块设备的信息,而且还能显示他们之间的依赖关系,但是不会列出 RAM 盘的信息

命令:lsblk [参数]

  • lsblk:以树状列出所有块设备

    NAME:这是块设备名

    MAJ:MIN : 本栏显示主要和次要设备号

    RM:本栏显示设备是否可移动设备,在上面设备 sr0 的 RM 值等于 1,这说明他们是可移动设备

    SIZE:本栏列出设备的容量大小信息

    RO:该项表明设备是否为只读,在本案例中,所有设备的 RO 值为 0,表明他们不是只读的

    TYPE:本栏显示块设备是否是磁盘或磁盘上的一个分区。在本例中,sda 和 sdb 是磁盘,而 sr0 是只读存储(rom)。

    MOUNTPOINT:本栏指出设备挂载的挂载点。

  • lsblk -f:不会列出所有空设备

    NAME表示设备名称

    FSTYPE表示文件类型

    LABEL表示设备标签

    UUID设备编号

    MOUNTPOINT表示设备的挂载点


df

df 命令用于显示目前在 Linux 系统上的文件系统的磁盘使用情况统计。

命令:df [options]… [FILE]…

  • -h 使用人类可读的格式(预设值是不加这个选项的…)
  • –total 计算所有的数据之和

第一列指定文件系统的名称;第二列指定一个特定的文件系统,1K 是 1024 字节为单位的总容量;已用和可用列分别指定的容量;最后一个已用列指定使用的容量的百分比;最后一栏指定的文件系统的挂载点


mount

mount 命令是经常会使用到的命令,它用于挂载 Linux 系统外的文件

使用者权限:所有用户,设置级别的需要管理员

1
2
3
4
mount [-hV]
mount -a [-fFnrsvw] [-t vfstype]
mount [-fnrsvw] [-o options [,...]] device | dir
mount [-fnrsvw] [-t vfstype] [-o options] device dir
  • -t:指定档案系统的型态,通常不必指定。mount 会自动选择正确的型态。

通过挂载的方式查看 Linux CD/DVD 光驱,查看 ubuntu-20.04.1-desktop-amd64.iso 的文件

  • 进入【虚拟机】–【设置】,设置 CD/DVD 的内容,ubuntu-20.04.1-desktop-amd64.iso

  • 创建挂载点(注意:一般用户无法挂载 cdrom,只有 root 用户才可以操作)

    mkdir -p /mnt/cdrom :切换到 root 下创建一个挂载点(其实就是创建一个目录)

  • 开始挂载
    mount -t auto /dev/cdrom /mnt/cdrom:通过挂载点的方式查看上面的【ISO文件内容】
    挂载成功

  • 查看挂载内容:ls -l -a ./mnt/cdrom/

  • 卸载 cdrom:umount /mnt/cdrom/


防火墙

概述

防火墙技术是通过有机结合各类用于安全管理与筛选的软件和硬件设备,帮助计算机网络于其内、外网之间构建一道相对隔绝的保护屏障,以保护用户资料与信息安全性的一种技术。在默认情况下,Linux 系统的防火墙状态是打开的

状态

启动语法:service name status

  • 查看防火墙状态:service iptables status

  • 临时开启:service iptables start

  • 临时关闭:service iptables stop

  • 开机启动:chkconfig iptables on

  • 开机关闭:chkconfig iptables off

放行

设置端口防火墙放行

  • 修改配置文件:vim /etc/sysconfig/iptables
  • 添加放行端口:-A INPUT -m state --state NEW -m tcp -p tcp --dport 端口号 -j ACCEPT
  • 重新加载防火墙规则:service iptables reload

备注:默认情况下 22 端口号是放行的


Shell

入门

概念

Shell 脚本(shell script),是一种为 shell 编写的脚本程序,又称 Shell 命令稿、程序化脚本,是一种计算机程序使用的文本文件,内容由一连串的 shell 命令组成,经由 Unix Shell 直译其内容后运作

Shell 被当成是一种脚本语言来设计,其运作方式与解释型语言相当,由 Unix shell 扮演命令行解释器的角色,在读取 shell 脚本之后,依序运行其中的 shell 命令,之后输出结果

环境

Shell 编程跟 JavaScript、php 编程一样,只要有一个能编写代码的文本编辑器和一个能解释执行的脚本解释器就可以了。

cat /etc/shells:查看解释器

Linux 的 Shell 种类众多,常见的有:

  • Bourne Shell(/usr/bin/sh或/bin/sh)

  • Bourne Again Shell(/bin/bash):Bash 是大多数Linux 系统默认的 Shell

  • C Shell(/usr/bin/csh)

  • K Shell(/usr/bin/ksh)

  • Shell for Root(/sbin/sh)

  • 等等……

第一个shell

  • 新建 s.sh 文件:touch s.sh

  • 编辑 s.sh 文件:vim s.sh

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    #!/bin/bash  --- 指定脚本解释器
    echo "你好,shell !" ---向窗口输入文本

    :<<!
    写shell的习惯 第一行指定解释器
    文件是sh为后缀名
    括号成对书写
    注释的时候尽量不用中文注释。不友好。
    [] 括号两端要要有空格。 [ neirong ]
    习惯代码索引,增加阅读性
    写语句的时候,尽量写全了,比如if。。。
    !
  • 查看 s.sh文件:ls -l s.sh文件权限是【-rw-rw-r–】

  • chmod a+x s.sh s.sh文件权限是【-rwxrwxr-x】

  • 执行文件:./s.sh

  • 或者直接 bash s.sh

注意:

#! 是一个约定的标记,告诉系统这个脚本需要什么解释器来执行,即使用哪一种 Shell

echo 命令用于向窗口输出文本


注释

  • 单行注释:以 # 开头的行就是注释,会被解释器忽略

  • 多行注释:

    1
    2
    3
    4
    :<<EOF
    注释内容...
    注释内容...
    EOF
    1
    2
    3
    4
    5
    :<<!      -----这里的符号要和结尾处的一样
    注释内容...
    注释内容...
    注释内容...
    !

变量

定义变量

变量名和等号之间不能有空格,这可能和你熟悉的所有编程语言都不一样。同时,变量名的命名须遵循如下规则:

  • 命名只能使用英文字母,数字和下划线,首个字符不能以数字开头。
  • 中间不能有空格,可以使用下划线(_)。
  • 不能使用标点符号。
  • 不能使用bash里的关键字(可用help命令查看保留关键字)。

使用变量

使用一个定义过的变量,只要在变量名前面加美元符号$即可

1
2
3
4
name="seazean"
echo $name
echo ${name}
name="zhy"
  • 已定义的变量,可以被重新定义变量名

  • 外面的花括号是可选的,加不加都行,加花括号是为了帮助解释器识别变量的边界。推荐加!!

    1
    2
    比如:echo "I am good at ${shell-t}Script"
    通过上面的脚本我们发现,如果不给shell-t变量加花括号,写成echo "I am good at $shell-tScript",解释器shell就会把$shell-tScript当成一个变量,由于我们前面没有定义shell-t变量,那么解释器执行执行的结果自然就为空了。

只读变量

使用 readonly 命令可以将变量定义为只读变量,只读变量的值不能被改变。(类似于final)

1
2
3
4
5
#!/bin/bash
myUrl="https://www.baidu.com"
readonly myUrl
myUrl="https://cn.bing.com/"
#报错 myUrl readonly

删除变量

使用 unset 命令可以删除变量,变量被删除后不能再次使用。

语法:unset variable_name

1
2
3
4
#!/bin/sh
myUrl="https://www.baidu.com"
unset myUrl
echo $myUrl

定义myUrl变量,通过unset删除变量,然后通过echo进行输出,结果是为空,没有任何的结果输出。

字符变量

字符串是shell编程中最常用也是最有用的数据类型,字符串可以用单引号,也可以用双引号,也可以不用引号,在Java SE中我们定义一个字符串通过Stirng s=“abc” 双引号的形式进行定义,而在shell中也是可以的。

引号
  • 单引号

    1
    str='this is a string variable'

    单引号字符串的限制:

    • 单引号里的任何字符都会原样输出,单引号字符串中的变量是无效的
    • 单引号字串中不能出现单独一个的单引号(对单引号使用转义符后也不行),但可成对出现,作为字符串拼接使用。
  • 双引号

    1
    2
    3
    your_name='frank'
    str="Hello,\"$your_name\"! \n"
    echo -e $str #Hello, "frank"!

    双引号的优点:

    • 双引号里可以有变量
    • 双引号里可以出现转义字符
拼接字符串
1
2
3
4
5
6
your_name="frank"
# 使用双引号拼接
greeting="hello, "$your_name" !"
greeting_1="hello, ${your_name} !"
echo $greeting $greeting_1
#hello,frank! hello,frank
获取字符串长度

命令:`$